From f83051be40514a2084ceb06007eea37b31dd3062 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 20:56:32 -0500 Subject: [PATCH 1/6] feat(api): improve create UX with URL validation and model mapping - Add URL warning for common mistakes (e.g., /chat/completions endpoint) - Add optional model mapping prompt for Opus/Sonnet/Haiku backends - Show edit hint after profile creation for modifying settings - Support custom model configurations in unified config mode Closes #72 --- src/commands/api-command.ts | 178 +++++++++++++++++++++++++++++------- 1 file changed, 143 insertions(+), 35 deletions(-) diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 4193711f..71a5468a 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -93,7 +93,7 @@ function validateApiName(name: string): string | null { } /** - * Validate URL format + * Validate URL format and warn about common mistakes */ function validateUrl(url: string): string | null { if (!url) { @@ -107,6 +107,25 @@ function validateUrl(url: string): string | null { } } +/** + * Check if URL looks like it includes endpoint path (common mistake) + * Returns warning message if problematic, null if OK + */ +function getUrlWarning(url: string): string | null { + const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + const lowerUrl = url.toLowerCase(); + + for (const path of problematicPaths) { + if (lowerUrl.endsWith(path)) { + return ( + `URL ends with "${path}" - Claude appends this automatically.\n` + + ` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}` + ); + } + } + return null; +} + /** * Check if unified config mode is active */ @@ -130,11 +149,24 @@ function apiExists(name: string): boolean { } } +/** Model mapping for API profiles */ +interface ModelMapping { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + /** * Create settings.json file for API profile * Includes all 4 model fields for proper Claude CLI integration */ -function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { +function createSettingsFile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping +): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); @@ -142,10 +174,10 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, }, }; @@ -192,7 +224,7 @@ function createApiProfileUnified( name: string, baseUrl: string, apiKey: string, - model: string + models: ModelMapping ): void { const ccsDir = path.join(os.homedir(), '.ccs'); const settingsFile = `${name}.settings.json`; @@ -203,10 +235,10 @@ function createApiProfileUnified( env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, }, }; @@ -293,9 +325,12 @@ async function handleCreate(args: string[]): Promise { // Step 2: Base URL let baseUrl = parsedArgs.baseUrl; if (!baseUrl) { - baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', { - validate: validateUrl, - }); + baseUrl = await InteractivePrompt.input( + 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', + { + validate: validateUrl, + } + ); } else { const error = validateUrl(baseUrl); if (error) { @@ -304,6 +339,23 @@ async function handleCreate(args: string[]): Promise { } } + // Check for common URL mistakes and warn + const urlWarning = getUrlWarning(baseUrl); + if (urlWarning) { + console.log(''); + console.log(warn(urlWarning)); + const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', { + default: false, + }); + if (!continueAnyway) { + // Let user re-enter URL + baseUrl = await InteractivePrompt.input('API Base URL', { + validate: validateUrl, + default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''), + }); + } + } + // Step 3: API Key let apiKey = parsedArgs.apiKey; if (!apiKey) { @@ -324,44 +376,100 @@ async function handleCreate(args: string[]): Promise { } model = model || defaultModel; + // Step 5: Optional model mapping for Opus/Sonnet/Haiku + // Ask user if they want different models for each type + let opusModel = model; + let sonnetModel = model; + let haikuModel = model; + + if (!parsedArgs.yes) { + console.log(''); + console.log(dim('Some API proxies route different model types to different backends.')); + const wantCustomMapping = await InteractivePrompt.confirm( + 'Configure different models for Opus/Sonnet/Haiku?', + { default: false } + ); + + if (wantCustomMapping) { + console.log(''); + console.log(dim('Leave blank to use the default model for each.')); + opusModel = (await InteractivePrompt.input('Opus model', { default: model })) || model; + sonnetModel = (await InteractivePrompt.input('Sonnet model', { default: model })) || model; + haikuModel = (await InteractivePrompt.input('Haiku model', { default: model })) || model; + } + } + + // Build model mapping + const models: ModelMapping = { + default: model, + opus: opusModel, + sonnet: sonnetModel, + haiku: haikuModel, + }; + + // Check if custom model mapping is configured + const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; + // Create files console.log(''); console.log(info('Creating API profile...')); try { + const settingsFile = `~/.ccs/${name}.settings.json`; + if (isUnifiedMode()) { // Use unified config format - createApiProfileUnified(name, baseUrl, apiKey, model); + createApiProfileUnified(name, baseUrl, apiKey, models); console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Config: ~/.ccs/config.yaml\n` + - `Secrets: ~/.ccs/secrets.yaml\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created (Unified Config)' - ) - ); + + // Build info message + let infoMsg = + `API: ${name}\n` + + `Config: ~/.ccs/config.yaml\n` + + `Settings: ${settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; + + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; + } + + console.log(infoBox(infoMsg, 'API Profile Created')); } else { // Use legacy JSON format - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + const settingsPath = createSettingsFile(name, baseUrl, apiKey, models); updateConfig(name, settingsPath); console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Settings: ~/.ccs/${name}.settings.json\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created' - ) - ); + + let infoMsg = + `API: ${name}\n` + + `Settings: ${settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; + + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; + } + + console.log(infoBox(infoMsg, 'API Profile Created')); } + console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); console.log(''); + console.log(header('Edit Settings')); + console.log(` ${dim('To modify env vars later:')}`); + console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`); + console.log(''); } catch (error) { console.log(fail(`Failed to create API profile: ${(error as Error).message}`)); process.exit(1); From 2b1a3b48799eae30b5d0493e5af65edab204f4d8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 21:09:38 -0500 Subject: [PATCH 2/6] feat(dashboard): add code editor for raw JSON settings editing - Add GET/PUT /api/file endpoints for generic file access with security validation - Add GET /api/files endpoint to list editable JSON files in ~/.ccs/ - Create CodeEditor component with JSON syntax highlighting (prism-react-renderer) - Add "Raw JSON" tab to SettingsDialog for direct JSON editing - Support conflict detection, atomic writes, and automatic backups - Lazy load editor to minimize initial bundle impact (~31KB gzipped) Closes #73 --- src/web-server/routes.ts | 187 ++++++++++++++++++++++++++ ui/bun.lock | 8 ++ ui/package.json | 2 + ui/src/components/code-editor.tsx | 164 ++++++++++++++++++++++ ui/src/components/settings-dialog.tsx | 103 ++++++++++++-- 5 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/code-editor.tsx diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 25007940..956cae88 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { keys: Object.keys(secrets), // Only key names, not values }); }); + +// ==================== Generic File API (Issue #73) ==================== + +/** + * Security: Validate file path is within allowed directories + * - ~/.ccs/ directory: read/write allowed + * - ~/.claude/settings.json: read-only + */ +function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } { + const expandedPath = expandPath(filePath); + const normalizedPath = path.normalize(expandedPath); + const ccsDir = getCcsDir(); + const claudeSettingsPath = expandPath('~/.claude/settings.json'); + + // Check if path is within ~/.ccs/ + if (normalizedPath.startsWith(ccsDir)) { + // Block access to sensitive subdirectories + const relativePath = normalizedPath.slice(ccsDir.length); + if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + return { valid: true, readonly: false }; + } + + // Allow read-only access to ~/.claude/settings.json + if (normalizedPath === claudeSettingsPath) { + return { valid: true, readonly: true }; + } + + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; +} + +/** + * GET /api/file - Read a file with path validation + * Query params: path (required) + * Returns: { content: string, mtime: number, readonly: boolean, path: string } + */ +apiRoutes.get('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + const expandedPath = expandPath(filePath); + + if (!fs.existsSync(expandedPath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + + try { + const stat = fs.statSync(expandedPath); + const content = fs.readFileSync(expandedPath, 'utf8'); + + res.json({ + content, + mtime: stat.mtime.getTime(), + readonly: validation.readonly, + path: expandedPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/file - Write a file with conflict detection and backup + * Query params: path (required) + * Body: { content: string, expectedMtime?: number } + * Returns: { success: true, mtime: number, backupPath?: string } + */ +apiRoutes.put('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + const { content, expectedMtime } = req.body; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + if (validation.readonly) { + res.status(403).json({ error: 'File is read-only' }); + return; + } + + const expandedPath = expandPath(filePath); + const ccsDir = getCcsDir(); + + // Conflict detection (if file exists and expectedMtime provided) + if (fs.existsSync(expandedPath) && expectedMtime !== undefined) { + const stat = fs.statSync(expandedPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } + } + + try { + // Create backup if file exists + let backupPath: string | undefined; + if (fs.existsSync(expandedPath)) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const filename = path.basename(expandedPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`); + fs.copyFileSync(expandedPath, backupPath); + } + + // Ensure parent directory exists + const parentDir = path.dirname(expandedPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + // Write atomically + const tempPath = expandedPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, expandedPath); + + const newStat = fs.statSync(expandedPath); + res.json({ + success: true, + mtime: newStat.mtime.getTime(), + backupPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/files - List editable files in ~/.ccs/ + * Returns: { files: Array<{ name: string, path: string, mtime: number }> } + */ +apiRoutes.get('/files', (_req: Request, res: Response): void => { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(ccsDir)) { + res.json({ files: [] }); + return; + } + + try { + const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => { + const filePath = path.join(ccsDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + path: `~/.ccs/${entry.name}`, + mtime: stat.mtime.getTime(), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/bun.lock b/ui/bun.lock index 14e0041b..f2f7ca2e 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -23,11 +23,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -387,6 +389,8 @@ "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -677,6 +681,8 @@ "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -701,6 +707,8 @@ "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], + "react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], diff --git a/ui/package.json b/ui/package.json index acd632e1..7e02cbdb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,11 +34,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx new file mode 100644 index 00000000..68a8bc8c --- /dev/null +++ b/ui/src/components/code-editor.tsx @@ -0,0 +1,164 @@ +/** + * Code Editor Component + * Lightweight JSON editor with syntax highlighting, line numbers, and validation + * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) + */ + +import { useState, useCallback, useMemo } 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'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + language?: 'json' | 'yaml'; + readonly?: boolean; + className?: string; + minHeight?: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; + line?: number; +} + +/** + * Validate JSON and extract error location + */ +function validateJson(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + JSON.parse(code); + return { valid: true }; + } catch (e) { + const error = e as SyntaxError; + const message = error.message; + + // Try to extract line number from error message + // Format: "... at position X" or "... at line Y column Z" + const posMatch = message.match(/position (\d+)/); + if (posMatch) { + const pos = parseInt(posMatch[1], 10); + const lines = code.substring(0, pos).split('\n'); + return { + valid: false, + error: message, + line: lines.length, + }; + } + + return { + valid: false, + error: message, + }; + } +} + +export function CodeEditor({ + value, + onChange, + language = 'json', + readonly = false, + className, + minHeight = '300px', +}: CodeEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + + // Validate on every change for JSON + const validation = useMemo(() => { + if (language === 'json') { + return validateJson(value); + } + return { valid: true }; + }, [value, language]); + + // Highlight function using prism-react-renderer + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => ( + <> + {tokens.map((line, i) => ( +
+ + {i + 1} + + + {line.map((token, key) => ( + + ))} + +
+ ))} + + )} +
+ ), + [isDark, language, validation.line] + ); + + return ( +
+ {/* Editor container */} +
+ {} : onChange} + highlight={highlightCode} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName={cn( + 'focus:outline-none font-mono text-sm', + readonly && 'cursor-not-allowed' + )} + preClassName="font-mono text-sm" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight, + }} + /> +
+ + {/* Validation status */} +
+ {validation.valid ? ( + + + Valid {language.toUpperCase()} + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + {readonly && (Read-only)} +
+
+ ); +} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx index d4b0f3b9..624ce907 100644 --- a/ui/src/components/settings-dialog.tsx +++ b/ui/src/components/settings-dialog.tsx @@ -1,10 +1,10 @@ /** * Settings Dialog Component * Reusable dialog for editing profile environment variables - * Features: masked inputs for sensitive keys, conflict detection, save/cancel + * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Dialog, @@ -18,9 +18,14 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, X, Loader2 } from 'lucide-react'; +import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + interface Settings { env?: Record; } @@ -55,6 +60,8 @@ function SettingsDialogContent({ }) { const [localEdits, setLocalEdits] = useState>({}); const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); const queryClient = useQueryClient(); // Fetch settings for selected profile @@ -63,6 +70,23 @@ function SettingsDialogContent({ queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), }); + // Derive raw JSON content: use edits if available, otherwise serialize from data + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + // Update raw JSON when user edits + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + // Derive current settings by merging original data with local edits const currentSettings = useMemo((): Settings | undefined => { const settings = data?.settings; @@ -76,16 +100,39 @@ function SettingsDialogContent({ }; }, [data?.settings, localEdits]); + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + // Save mutation const saveMutation = useMutation({ mutationFn: async () => { - const settingsToSave: Settings = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; + let settingsToSave: Settings; + + // Determine what to save based on active tab + if (activeTab === 'raw') { + // Parse raw JSON content + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + // Use form-based edits + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } const res = await fetch(`/api/settings/${profileName}`, { method: 'PUT', @@ -174,7 +221,11 @@ function SettingsDialogContent({ ) : (
- + Environment + + + Raw JSON + + + + + Loading editor... +
+ } + > + + + + @@ -252,7 +328,10 @@ function SettingsDialogContent({ - +
+

Create API Profile

+

Configure a new custom API endpoint

+
+ + + +
+
+ {/* Basic Info Card */} + + + Basic Information + Profile name and API connection details + + + {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used as: ccs my-api "prompt" +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ Base URL without /chat/completions (Claude adds this) +

+ )} +
+ + {/* API Key */} +
+ + + {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + {/* Model Configuration Card */} + + + Model Configuration + Configure which models to use with this API + + + {/* Default Model */} +
+ + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Expander */} +
+ + + {showModelMapping && ( +
+
+ + + Configure different model IDs for each tier. Useful for API proxies that + route Opus/Sonnet/Haiku to different backends. + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+
+ + ); +} diff --git a/ui/src/components/profile-dialog.tsx b/ui/src/components/profile-dialog.tsx index 3734c5ce..183f2f24 100644 --- a/ui/src/components/profile-dialog.tsx +++ b/ui/src/components/profile-dialog.tsx @@ -1,8 +1,10 @@ /** * Profile Dialog Component * Phase 03: REST API Routes & CRUD + * Updated: Added model mapping fields for Opus/Sonnet/Haiku */ +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -12,6 +14,9 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; const schema = z.object({ name: z @@ -21,6 +26,9 @@ const schema = z.object({ baseUrl: z.string().url('Invalid URL'), 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; @@ -34,12 +42,14 @@ interface ProfileDialogProps { export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { const createMutation = useCreateProfile(); const updateMutation = useUpdateProfile(); + const [showModelMapping, setShowModelMapping] = useState(false); const { register, handleSubmit, formState: { errors }, reset, + watch, } = useForm({ resolver: zodResolver(schema), defaultValues: profile @@ -48,10 +58,30 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: '', apiKey: '', model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', } : undefined, }); + // Watch model field to auto-expand model mapping when custom model is entered + const modelValue = watch('model'); + + useEffect(() => { + // Auto-show model mapping if user enters a custom model (not default) + if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { + setShowModelMapping(true); + } + }, [modelValue]); + + // Reset state when dialog opens/closes + useEffect(() => { + if (!open) { + setShowModelMapping(false); + } + }, [open]); + const onSubmit = async (data: FormData) => { try { if (profile) { @@ -62,6 +92,9 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: data.baseUrl, apiKey: data.apiKey, model: data.model, + opusModel: data.opusModel, + sonnetModel: data.sonnetModel, + haikuModel: data.haikuModel, }, }); } else { @@ -78,7 +111,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { return ( - + {profile ? 'Edit Profile' : 'Create API Profile'} @@ -104,8 +137,72 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
- - + + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Section */} +
+ + + {showModelMapping && ( +
+

+ Configure different model IDs for each tier. Useful for API proxies that route + different model types to different backends. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )}
diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx new file mode 100644 index 00000000..11190a95 --- /dev/null +++ b/ui/src/components/profile-editor.tsx @@ -0,0 +1,528 @@ +/** + * Profile Editor Component + * Inline editor for API profile settings with tabs for Environment/Raw JSON/Info + */ + +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { + Save, + Loader2, + Code2, + Settings, + Info, + Terminal, + Trash2, + RefreshCw, + Plus, + X, +} from 'lucide-react'; +import { toast } from 'sonner'; + +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface Settings { + env?: Record; +} + +interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +interface ProfileEditorProps { + profileName: string; + onDelete?: () => void; +} + +export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); + const [newEnvKey, setNewEnvKey] = useState(''); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), + }); + + // Derive raw JSON content + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + if (!settings) return undefined; + return { + ...settings, + env: { + ...settings.env, + ...localEdits, + }, + }; + }, [settings, localEdits]); + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + if (activeTab === 'raw') { + return rawJsonEdits !== null; + } + return Object.keys(localEdits).length > 0; + }, [activeTab, rawJsonEdits, localEdits]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + if (activeTab === 'raw') { + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + setLocalEdits({}); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleSave = () => { + saveMutation.mutate(); + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setLocalEdits({}); + setRawJsonEdits(null); + } + }; + + const updateEnvValue = (key: string, value: string) => { + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + setLocalEdits((prev) => ({ + ...prev, + [newEnvKey.trim()]: '', + })); + setNewEnvKey(''); + }; + + const isSensitiveKey = (key: string): boolean => { + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, + /_API_KEY$/, + /_AUTH_TOKEN$/, + /^API_KEY$/, + /^AUTH_TOKEN$/, + /_SECRET$/, + /^SECRET$/, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); + }; + + // Reset state when profile changes + const profileKey = profileName; + + return ( +
+ {/* Header */} +
+
+
+

{profileName}

+ {data && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+ + {onDelete && ( + + )} + +
+
+ + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( + +
+ + + + Environment + + + + Raw JSON + + + + Usage + + + + Info + + +
+ + {/* Environment Tab */} + + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + )} +
+ ))} + + {/* Add new env var */} +
+ +
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+ + ) : ( +
+

No environment variables configured.

+

+ Add variables using the Raw JSON tab or the form below. +

+
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm max-w-xs" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+ )} +
+
+
+ + {/* Raw JSON Tab */} + + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+ +
+
+ + + + {/* Usage Tab */} + + +
+ + + CLI Usage + Use this profile from the command line + + +
+ + + ccs {profileName} "your prompt here" + +
+
+ + + ccs {profileName} + +
+
+ + + ccs default {profileName} + +
+
+
+ + + + Environment Variables + Variables set when using this profile + + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {key} + + + {isSensitiveKey(key) ? '••••••••' : value} + +
+ )) + ) : ( +

+ No environment variables set +

+ )} +
+
+
+
+
+
+ + {/* Info Tab */} + + +
+ + + Profile Information + Details about this configuration file + + + {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path + + {data.path} + +
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+
+ Variables + {Object.keys(currentSettings?.env || {}).length} configured +
+ + )} +
+
+
+
+
+ + )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 4a8932a0..40a6a0c7 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -31,12 +31,18 @@ export interface CreateProfile { baseUrl: string; apiKey: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface UpdateProfile { baseUrl?: string; apiKey?: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface Variant { diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 74c0e72b..d0229df3 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -1,66 +1,331 @@ /** - * API Profiles Page - * Phase 03: REST API Routes & CRUD + * API Profiles Page - Master-Detail Layout + * Comprehensive profile management with inline editing */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; -import { Plus } from 'lucide-react'; -import { ProfilesTable } from '@/components/profiles-table'; -import { ProfileDialog } from '@/components/profile-dialog'; -import { SettingsDialog } from '@/components/settings-dialog'; -import { useProfiles } from '@/hooks/use-profiles'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + Plus, + Search, + Settings2, + Trash2, + CheckCircle2, + AlertCircle, + Server, + ExternalLink, + FileJson, +} from 'lucide-react'; +import { ProfileEditor } from '@/components/profile-editor'; +import { ProfileCreateForm } from '@/components/profile-create-form'; +import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; +import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; export function ApiPage() { - const [dialogOpen, setDialogOpen] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); - const [settingsProfileName, setSettingsProfileName] = useState(null); const { data, isLoading } = useProfiles(); + const deleteMutation = useDeleteProfile(); + const [selectedProfile, setSelectedProfile] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); - const handleEditSettings = (profile: Profile) => { - setSettingsProfileName(profile.name); - setSettingsDialogOpen(true); + // Memoize profiles to maintain stable reference + const profiles = useMemo(() => data?.profiles || [], [data?.profiles]); + + // Filter profiles by search + const filteredProfiles = useMemo( + () => profiles.filter((p) => p.name.toLowerCase().includes(searchQuery.toLowerCase())), + [profiles, searchQuery] + ); + + // 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]); + + // Handle profile deletion + const handleDelete = (name: string) => { + deleteMutation.mutate(name, { + onSuccess: () => { + if (selectedProfile === name) { + setSelectedProfile(null); + } + setDeleteConfirm(null); + }, + }); }; - const handleCloseDialog = () => { - setDialogOpen(false); - setEditingProfile(null); + // Handle create success + const handleCreateSuccess = (name: string) => { + setShowCreateForm(false); + setSelectedProfile(name); }; - const handleCloseSettingsDialog = () => { - setSettingsDialogOpen(false); - setSettingsProfileName(null); - }; + const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile); return ( -
-
-
-

API Profiles

-

- Manage custom API profiles for Claude CLI -

+
+ {/* Left Panel - Profiles List */} +
+ {/* Header */} +
+
+
+ +

API Profiles

+
+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + /> +
- + + {/* Profile List */} + + {isLoading ? ( +
Loading profiles...
+ ) : filteredProfiles.length === 0 ? ( +
+ {profiles.length === 0 ? ( +
+ +
+

No API profiles yet

+

+ Create your first profile to connect to custom API endpoints +

+
+ +
+ ) : ( +

+ No profiles match "{searchQuery}" +

+ )} +
+ ) : ( +
+ {filteredProfiles.map((profile) => ( + { + setSelectedProfile(profile.name); + setShowCreateForm(false); + }} + onDelete={() => setDeleteConfirm(profile.name)} + /> + ))} +
+ )} +
+ + {/* Footer Stats */} + {profiles.length > 0 && ( +
+
+ + {profiles.length} profile{profiles.length !== 1 ? 's' : ''} + + + + {profiles.filter((p) => p.configured).length} configured + +
+
+ )}
- {isLoading ? ( -
Loading profiles...
- ) : ( - - )} + {/* Right Panel - Editor */} +
+ {showCreateForm ? ( + { + setShowCreateForm(false); + if (profiles.length > 0) { + setSelectedProfile(profiles[0].name); + } + }} + /> + ) : selectedProfileData ? ( + setDeleteConfirm(selectedProfileData.name)} + /> + ) : ( + { + setShowCreateForm(true); + setSelectedProfile(null); + }} + /> + )} +
- - deleteConfirm && handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} />
); } + +/** Profile list item component */ +function ProfileListItem({ + profile, + isSelected, + onSelect, + onDelete, +}: { + profile: Profile; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Status indicator */} + {profile.configured ? ( + + ) : ( + + )} + + {/* Profile info */} +
+
{profile.name}
+
{profile.settingsPath}
+
+ + {/* Actions */} + +
+ ); +} + +/** Empty state when no profile is selected */ +function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { + return ( +
+
+ +

API Profile Manager

+

+ Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api, + OpenRouter, or your own API backend. +

+ +
+ + + + +
+

+ What you can configure: +

+
    +
  • + + URL + + Custom API base URL endpoint +
  • +
  • + + Auth + + API key or authentication token +
  • +
  • + + Models + + Model mapping for Opus/Sonnet/Haiku +
  • +
+
+ + +
+
+
+ ); +} From 8c9d669ccec6d2c56c37f4421e5ca6d4c95703e3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 22:14:01 -0500 Subject: [PATCH 4/6] feat(api-profile-ux): implement tabbed profile editor and fix disclaimer visibility --- ui/src/App.tsx | 12 +- ui/src/components/localhost-disclaimer.tsx | 19 +- ui/src/components/profile-editor.tsx | 558 ++++++++++----------- ui/src/components/ui/copy-button.tsx | 60 +++ ui/src/pages/api.tsx | 14 +- 5 files changed, 348 insertions(+), 315 deletions(-) create mode 100644 ui/src/components/ui/copy-button.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c9ab07be..8ba2fe22 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -43,16 +43,18 @@ function Layout() { return ( -
-
+
+
- }> - - +
+ }> + + +
diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx index 23a1c778..c7947163 100644 --- a/ui/src/components/localhost-disclaimer.tsx +++ b/ui/src/components/localhost-disclaimer.tsx @@ -1,30 +1,13 @@ import { Shield, X } from 'lucide-react'; import { useState } from 'react'; -import { useSidebar } from '@/hooks/use-sidebar'; export function LocalhostDisclaimer() { const [dismissed, setDismissed] = useState(false); - const { state, isMobile } = useSidebar(); if (dismissed) return null; - // Calculate the left margin based on sidebar state - // When expanded: sidebar width is 16rem - // When collapsed: sidebar width is 3rem - // On mobile: sidebar is overlay, no margin needed - const getLeftMargin = () => { - if (isMobile) return '0'; - return state === 'expanded' ? '16rem' : '3rem'; - }; - return ( -
+
diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx index 11190a95..8808ee00 100644 --- a/ui/src/components/profile-editor.tsx +++ b/ui/src/components/profile-editor.tsx @@ -1,6 +1,6 @@ /** * Profile Editor Component - * Inline editor for API profile settings with tabs for Environment/Raw JSON/Info + * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) */ import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; @@ -9,24 +9,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import { - Save, - Loader2, - Code2, - Settings, - Info, - Terminal, - Trash2, - RefreshCw, - Plus, - X, -} from 'lucide-react'; +import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react'; import { toast } from 'sonner'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; // Lazy load CodeEditor to reduce initial bundle size const CodeEditor = lazy(() => @@ -53,7 +42,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { const [localEdits, setLocalEdits] = useState>({}); const [conflictDialog, setConflictDialog] = useState(false); const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [activeTab, setActiveTab] = useState('env'); const [newEnvKey, setNewEnvKey] = useState(''); const queryClient = useQueryClient(); @@ -80,7 +68,18 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { }, []); // Derive current settings by merging original data with local edits + // Prioritize rawJsonEdits if available const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // If invalid JSON, fall back to undefined or partial state + // The UI will likely show empty or potentially broken state if JSON is invalid, + // but the Raw Editor will show the error. + } + } + if (!settings) return undefined; return { ...settings, @@ -89,7 +88,38 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { ...localEdits, }, }; - }, [settings, localEdits]); + }, [settings, localEdits, rawJsonEdits]); + + // Sync Visual Editor changes to Raw JSON + const updateEnvValue = (key: string, value: string) => { + const newEnv = { ...(currentSettings?.env || {}), [key]: value }; + + // Update local edits + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + + // Update rawJsonEdits to keep sync + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + const key = newEnvKey.trim(); + const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; + + setLocalEdits((prev) => ({ + ...prev, + [key]: '', + })); + + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + + setNewEnvKey(''); + }; // Check if raw JSON is valid const isRawJsonValid = useMemo(() => { @@ -103,24 +133,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { // Check if there are unsaved changes const hasChanges = useMemo(() => { - if (activeTab === 'raw') { - return rawJsonEdits !== null; + if (rawJsonEdits !== null) { + return rawJsonEdits !== JSON.stringify(settings, null, 2); } return Object.keys(localEdits).length > 0; - }, [activeTab, rawJsonEdits, localEdits]); + }, [rawJsonEdits, localEdits, settings]); // Save mutation const saveMutation = useMutation({ mutationFn: async () => { let settingsToSave: Settings; - if (activeTab === 'raw') { - try { - settingsToSave = JSON.parse(rawJsonContent); - } catch { - throw new Error('Invalid JSON'); - } - } else { + try { + // Always save from rawJsonContent as it's the source of truth + settingsToSave = JSON.parse(rawJsonContent); + } catch { + // Fallback (should typically not happen if validation is correct) settingsToSave = { ...data?.settings, env: { @@ -180,22 +208,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { } }; - const updateEnvValue = (key: string, value: string) => { - setLocalEdits((prev) => ({ - ...prev, - [key]: value, - })); - }; - - const addNewEnvVar = () => { - if (!newEnvKey.trim()) return; - setLocalEdits((prev) => ({ - ...prev, - [newEnvKey.trim()]: '', - })); - setNewEnvKey(''); - }; - const isSensitiveKey = (key: string): boolean => { const sensitivePatterns = [ /^ANTHROPIC_AUTH_TOKEN$/, @@ -212,10 +224,206 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { // Reset state when profile changes const profileKey = profileName; + // Render Left Column Content (Environment + Info + Usage) + const renderFriendlyUI = () => ( +
+ +
+ + + Environment Variables + + + Info & Usage + + +
+ +
+ + {/* Scrollable Environment Variables List */} + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm h-8" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm h-8" + /> + )} +
+ ))} + + ) : ( +
+

No environment variables configured.

+

+ Add variables using the input below or edit the JSON directly. +

+
+ )} +
+
+ + {/* Fixed Add Input at Bottom */} +
+ +
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm h-8" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+
+ + + +
+ {/* Profile Information */} +
+

+ + Profile Information +

+
+ {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+
+ + {/* Usage */} +
+

Quick Usage

+
+
+ +
+ + ccs {profileName} "prompt" + + +
+
+
+ +
+ + ccs default {profileName} + + +
+
+
+
+
+
+
+
+
+
+ ); + + // Render Right Column Content (Raw JSON Editor) + const renderRawEditor = () => ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+
+ + ); + return (
{/* Header */} -
+

{profileName}

@@ -243,9 +451,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
) : ( - -
- - - - Environment - - - - Raw JSON - - - - Usage - - - - Info - - + // Split Layout (40% Left / 60% Right) +
+ {/* Left Column: Friendly UI */} +
{renderFriendlyUI()}
+ + {/* Right Column: Raw Editor */} +
+
+ + + Raw Configuration (JSON) + +
+ {renderRawEditor()}
- - {/* Environment Tab */} - - -
- {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( - <> - {Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {isSensitiveKey(key) ? ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm" - /> - ) : ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm" - /> - )} -
- ))} - - {/* Add new env var */} -
- -
- setNewEnvKey(e.target.value.toUpperCase())} - className="font-mono text-sm" - onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} - /> - -
-
- - ) : ( -
-

No environment variables configured.

-

- Add variables using the Raw JSON tab or the form below. -

-
- setNewEnvKey(e.target.value.toUpperCase())} - className="font-mono text-sm max-w-xs" - onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} - /> - -
-
- )} -
-
-
- - {/* Raw JSON Tab */} - - - - Loading editor... -
- } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
- -
-
- - - - {/* Usage Tab */} - - -
- - - CLI Usage - Use this profile from the command line - - -
- - - ccs {profileName} "your prompt here" - -
-
- - - ccs {profileName} - -
-
- - - ccs default {profileName} - -
-
-
- - - - Environment Variables - Variables set when using this profile - - -
- {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( - Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {key} - - - {isSensitiveKey(key) ? '••••••••' : value} - -
- )) - ) : ( -

- No environment variables set -

- )} -
-
-
-
-
-
- - {/* Info Tab */} - - -
- - - Profile Information - Details about this configuration file - - - {data && ( - <> -
- Profile Name - {data.profile} -
-
- File Path - - {data.path} - -
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
-
- Variables - {Object.keys(currentSettings?.env || {}).length} configured -
- - )} -
-
-
-
-
- +
)} { + navigator.clipboard.writeText(value); + setHasCopied(true); + setTimeout(() => setHasCopied(false), 2000); + }; + + return ( + + + + + + +

{hasCopied ? 'Copied!' : label}

+
+
+
+ ); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index d0229df3..aa21b1d2 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -26,6 +26,7 @@ import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; import { cn } from '@/lib/utils'; +import { CopyButton } from '@/components/ui/copy-button'; export function ApiPage() { const { data, isLoading } = useProfiles(); @@ -74,7 +75,7 @@ export function ApiPage() { const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile); return ( -
+
{/* Left Panel - Profiles List */}
{/* Header */} @@ -248,7 +249,16 @@ function ProfileListItem({ {/* Profile info */}
{profile.name}
-
{profile.settingsPath}
+
+
+ {profile.settingsPath} +
+ +
{/* Actions */} From 46ee1df0836fac4bb6b4b75413846163ced2fc6f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 22:50:01 -0500 Subject: [PATCH 5/6] fix(ui): resolve layout and theme issues in profile editor - Fix overflow issues in main layout by removing pb-12 and adding min-h-0 - Fix code editor theme switching by adding key prop to force re-render - Fix localhost disclaimer positioning by removing fixed positioning and redundant padding - Simplify analytics page layout structure and adjust spacing --- ui/src/App.tsx | 2 +- ui/src/components/code-editor.tsx | 1 + ui/src/components/localhost-disclaimer.tsx | 2 +- ui/src/pages/analytics.tsx | 422 ++++++++++----------- 4 files changed, 213 insertions(+), 214 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8ba2fe22..8b4801b3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -50,7 +50,7 @@ function Layout() {
-
+
}> diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx index 68a8bc8c..16243dc6 100644 --- a/ui/src/components/code-editor.tsx +++ b/ui/src/components/code-editor.tsx @@ -126,6 +126,7 @@ export function CodeEditor({ value={value} onValueChange={readonly ? () => {} : onChange} highlight={highlightCode} + key={isDark ? 'dark' : 'light'} padding={12} disabled={readonly} onFocus={() => setIsFocused(true)} diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx index c7947163..78d5c6e1 100644 --- a/ui/src/components/localhost-disclaimer.tsx +++ b/ui/src/components/localhost-disclaimer.tsx @@ -7,7 +7,7 @@ export function LocalhostDisclaimer() { if (dismissed) return null; return ( -
+
diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index f0f03dc1..dc2c313b 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -96,230 +96,228 @@ export function AnalyticsPage() { }, []); return ( -
-
- {/* Header */} -
-
-

Analytics

-

Track usage & insights

-
-
- - {lastUpdatedText && ( - - Updated {lastUpdatedText} - - )} - -
+
+ {/* Header */} +
+
+

Analytics

+

Track usage & insights

+
+ + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
- {/* Summary Cards */} - + {/* Summary Cards */} + - {/* Main Content */} -
- {/* Usage Trend Chart - Full Width */} - + {/* Main Content */} +
+ {/* Usage Trend Chart - Full Width */} + + + + + Usage Trends + + + + + + + + {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} +
+ {/* Cost by Model - 4/10 width with breakdown */} + - - Usage Trends + + Cost by Model - - + + {isModelsLoading ? ( + + ) : ( +
+ {[...(models || [])] + .sort((a, b) => b.cost - a.cost) + .map((model) => ( + + ))} + {/* Legend */} +
+ +
+ Input + + +
+ Output + + +
+ Cache Write + + +
+ Cache Read + +
+
+ )} - {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} -
- {/* Cost by Model - 4/10 width with breakdown */} - - - - - Cost by Model - - - - {isModelsLoading ? ( - - ) : ( -
- {[...(models || [])] - .sort((a, b) => b.cost - a.cost) - .map((model) => ( - - ))} - {/* Legend */} -
- -
- Input - - -
- Output - - -
- Cache Write - - -
- Cache Read - -
-
- )} - - - - {/* Model Distribution - 2/10 width */} - - - - - Model Usage - - - - - - - - {/* Session Stats - 2/10 width */} - - - {/* Usage Insights - 2/10 width */} - -
- - {/* Model Details Popover - positioned at cursor */} - !open && handlePopoverClose()}> - -
+ + + + Model Usage + + + + - - - {selectedModel && } - - + + + + {/* Session Stats - 2/10 width */} + + + {/* Usage Insights - 2/10 width */} +
+ + {/* Model Details Popover - positioned at cursor */} + !open && handlePopoverClose()}> + +
+ + + {selectedModel && } + +
); From 720ff9d7d6eb881a73547daab262030fb619e5ee Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 23:38:43 -0500 Subject: [PATCH 6/6] feat(profile): refactor create UX with dialog-based interface - Replace ProfileCreateForm with ProfileCreateDialog for better UX - Add Select component for dropdown functionality - Implement sensitive keys detection and masking - Enhance code editor with improved validation - Update dependencies and lock files --- ui/bun.lock | 7 + ui/package.json | 1 + ui/src/components/code-editor.tsx | 120 +++++-- ui/src/components/profile-create-dialog.tsx | 344 ++++++++++++++++++++ ui/src/components/profile-create-form.tsx | 319 ------------------ ui/src/components/ui/select.tsx | 150 +++++++++ ui/src/lib/sensitive-keys.ts | 35 ++ ui/src/pages/api.tsx | 38 +-- 8 files changed, 648 insertions(+), 366 deletions(-) create mode 100644 ui/src/components/profile-create-dialog.tsx delete mode 100644 ui/src/components/profile-create-form.tsx create mode 100644 ui/src/components/ui/select.tsx create mode 100644 ui/src/lib/sensitive-keys.ts 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 ( + + + + + + Create API Profile + + Configure a custom API endpoint for Claude Code. + + +
+ +
+ + + Basic Information + {hasBasicErrors && ( + + )} + + + Model Configuration + {hasModelErrors && ( + + )} + + +
+ +
+ +
+ {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used in CLI:{' '} + + ccs my-api "prompt" + +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ The endpoint that accepts OpenAI-compatible and Anthropic requests +

+ )} +
+ + {/* API Key */} +
+ +
+ + +
+ {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + +
+ +
+

Model Mapping

+

+ Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers + to the specific models supported by your API provider. +

+
+
+ +
+
+ + +

+ Fallback model if no specific tier is requested +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + + +
+
+
+
+ ); +} 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

-
-
- - -
-
- {/* Basic Info Card */} - - - Basic Information - Profile name and API connection details - - - {/* Name */} -
- - - {errors.name ? ( -

{errors.name.message}

- ) : ( -

- Used as: ccs my-api "prompt" -

- )} -
- - {/* Base URL */} -
- - - {errors.baseUrl ? ( -

{errors.baseUrl.message}

- ) : urlWarning ? ( -
- - {urlWarning} -
- ) : ( -

- Base URL without /chat/completions (Claude adds this) -

- )} -
- - {/* API Key */} -
- - - {errors.apiKey && ( -

{errors.apiKey.message}

- )} -
-
-
- - {/* Model Configuration Card */} - - - Model Configuration - Configure which models to use with this API - - - {/* Default Model */} -
- - -

- Leave blank to use: {DEFAULT_MODEL} -

-
- - {/* Model Mapping Expander */} -
- - - {showModelMapping && ( -
-
- - - Configure different model IDs for each tier. Useful for API proxies that - route Opus/Sonnet/Haiku to different backends. - -
- -
- - -
- -
- - -
- -
- - -
-
- )} -
-
-
- - {/* Actions */} -
- - -
-
-
-
-
- ); -} 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() {