diff --git a/packages/cli/src/runtime/runtimeSettings.ts b/packages/cli/src/runtime/runtimeSettings.ts index 19f91b6c1..55cd271f7 100644 --- a/packages/cli/src/runtime/runtimeSettings.ts +++ b/packages/cli/src/runtime/runtimeSettings.ts @@ -1207,6 +1207,11 @@ export async function listSavedProfiles(): Promise { return manager.listProfiles(); } +export async function getProfileByName(profileName: string): Promise { + const manager = new ProfileManager(); + return manager.loadProfile(profileName); +} + export function getActiveProfileName(): string | null { const { settingsService } = getCliRuntimeServices(); if (typeof settingsService.getCurrentProfileName === 'function') { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d9ee919cc..13e086590 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -98,6 +98,7 @@ import { useProviderModelDialog } from './hooks/useProviderModelDialog.js'; import { useProviderDialog } from './hooks/useProviderDialog.js'; import { useLoadProfileDialog } from './hooks/useLoadProfileDialog.js'; import { useCreateProfileDialog } from './hooks/useCreateProfileDialog.js'; +import { useProfileManagement } from './hooks/useProfileManagement.js'; import { useToolsDialog } from './hooks/useToolsDialog.js'; import { shouldUpdateTokenMetrics, @@ -1224,6 +1225,36 @@ export const AppContainer = (props: AppContainerProps) => { appState, }); + const { + showListDialog: isProfileListDialogOpen, + showDetailDialog: isProfileDetailDialogOpen, + showEditorDialog: isProfileEditorDialogOpen, + profiles: profileListItems, + isLoading: profileDialogLoading, + selectedProfileName, + selectedProfile: selectedProfileData, + defaultProfileName, + activeProfileName, + profileError: profileDialogError, + openListDialog: openProfileListDialog, + closeListDialog: closeProfileListDialog, + viewProfileDetail, + closeDetailDialog: closeProfileDetailDialog, + loadProfile: loadProfileFromDetail, + deleteProfile: deleteProfileFromDetail, + setDefault: setProfileAsDefault, + openEditor: openProfileEditor, + closeEditor: closeProfileEditor, + saveProfile: saveProfileFromEditor, + } = useProfileManagement({ + addMessage: (msg) => + addItem( + { type: msg.type as MessageType, text: msg.content }, + msg.timestamp.getTime(), + ), + appState, + }); + const { showDialog: isToolsDialogOpen, openDialog: openToolsDialogRaw, @@ -1413,6 +1444,9 @@ export const AppContainer = (props: AppContainerProps) => { openProviderDialog, openLoadProfileDialog, openCreateProfileDialog, + openProfileListDialog, + viewProfileDetail, + openProfileEditor, quit: setQuittingMessages, setDebugMessage, toggleCorgiMode, @@ -1433,6 +1467,9 @@ export const AppContainer = (props: AppContainerProps) => { openProviderDialog, openLoadProfileDialog, openCreateProfileDialog, + openProfileListDialog, + viewProfileDetail, + openProfileEditor, setQuittingMessages, setDebugMessage, toggleCorgiMode, @@ -2063,6 +2100,9 @@ export const AppContainer = (props: AppContainerProps) => { isProviderModelDialogOpen, isLoadProfileDialogOpen, isCreateProfileDialogOpen, + isProfileListDialogOpen, + isProfileDetailDialogOpen, + isProfileEditorDialogOpen, isToolsDialogOpen, isFolderTrustDialogOpen, showWorkspaceMigrationDialog, @@ -2088,6 +2128,15 @@ export const AppContainer = (props: AppContainerProps) => { subagentDialogInitialView, subagentDialogInitialName, + // Profile management dialog data + profileListItems, + selectedProfileName, + selectedProfileData, + defaultProfileName, + activeProfileName, + profileDialogError, + profileDialogLoading, + // Confirmation requests shellConfirmationRequest, confirmationRequest, @@ -2239,6 +2288,18 @@ export const AppContainer = (props: AppContainerProps) => { openCreateProfileDialog, exitCreateProfileDialog, + // Profile management dialogs + openProfileListDialog, + closeProfileListDialog, + viewProfileDetail, + closeProfileDetailDialog, + loadProfileFromDetail, + deleteProfileFromDetail, + setProfileAsDefault, + openProfileEditor, + closeProfileEditor, + saveProfileFromEditor, + // Tools dialog openToolsDialog, handleToolsSelect, @@ -2339,6 +2400,16 @@ export const AppContainer = (props: AppContainerProps) => { exitLoadProfileDialog, openCreateProfileDialog, exitCreateProfileDialog, + openProfileListDialog, + closeProfileListDialog, + viewProfileDetail, + closeProfileDetailDialog, + loadProfileFromDetail, + deleteProfileFromDetail, + setProfileAsDefault, + openProfileEditor, + closeProfileEditor, + saveProfileFromEditor, openToolsDialog, handleToolsSelect, exitToolsDialog, diff --git a/packages/cli/src/ui/commands/profileCommand.test.ts b/packages/cli/src/ui/commands/profileCommand.test.ts index bba1559fc..911d012f8 100644 --- a/packages/cli/src/ui/commands/profileCommand.test.ts +++ b/packages/cli/src/ui/commands/profileCommand.test.ts @@ -155,13 +155,10 @@ describe('profileCommand', () => { (cmd) => cmd?.name === 'list', )!; - it('lists saved profiles', async () => { + it('opens the profile list dialog', async () => { const result = await list.action!(context, ''); - expect(runtimeMocks.listSavedProfiles).toHaveBeenCalled(); - expect(result?.type).toBe('message'); - expect(result).toBeDefined(); - expect((result as { content: string }).content).toContain('alpha'); - expect((result as { content: string }).content).toContain('beta'); + expect(result?.type).toBe('dialog'); + expect((result as { dialog: string }).dialog).toBe('profileList'); }); }); diff --git a/packages/cli/src/ui/commands/profileCommand.ts b/packages/cli/src/ui/commands/profileCommand.ts index c2607ea1a..10a5652c5 100644 --- a/packages/cli/src/ui/commands/profileCommand.ts +++ b/packages/cli/src/ui/commands/profileCommand.ts @@ -893,40 +893,154 @@ const createCommand: SlashCommand = { /** * Profile list subcommand + * Opens interactive profile list dialog */ const listCommand: SlashCommand = { name: 'list', - description: 'list all saved profiles', + description: 'list all saved profiles (interactive)', kind: CommandKind.BUILT_IN, action: async ( _context: CommandContext, _args: string, - ): Promise => { + ): Promise => { + // Open interactive profile list dialog + logger.log(() => 'list action returning profileList dialog'); + return { + type: 'dialog', + dialog: 'profileList', + }; + }, +}; + +const profileShowSchema: CommandArgumentSchema = [ + { + kind: 'value', + name: 'profile', + description: 'Select profile to view', + completer: profileNameCompleter, + }, +]; + +/** + * Profile show subcommand + * Opens profile detail dialog directly for the specified profile + */ +const showCommand: SlashCommand = { + name: 'show', + description: 'view details of a specific profile', + kind: CommandKind.BUILT_IN, + schema: profileShowSchema, + action: async ( + _context: CommandContext, + args: string, + ): Promise => { + const trimmedArgs = args?.trim(); + + if (!trimmedArgs) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /profile show ', + }; + } + + // Extract profile name - handle quoted names + const profileNameMatch = trimmedArgs.match(/^"([^"]+)"$/); + const profileName = profileNameMatch ? profileNameMatch[1] : trimmedArgs; + + if (!profileName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /profile show ', + }; + } + + // Verify profile exists try { const profiles = await listProfiles(); - - if (profiles.length === 0) { + if (!profiles.includes(profileName)) { return { type: 'message', - messageType: 'info', - content: - 'No profiles saved yet. Use /profile save "" to create one.', + messageType: 'error', + content: `Profile '${profileName}' not found. Use /profile list to see available profiles.`, }; } + } catch { + // Continue anyway, the dialog will show the error + } + + return { + type: 'dialog', + dialog: 'profileDetail', + dialogData: { profileName }, + }; + }, +}; + +const profileEditSchema: CommandArgumentSchema = [ + { + kind: 'value', + name: 'profile', + description: 'Select profile to edit', + completer: profileNameCompleter, + }, +]; + +/** + * Profile edit subcommand + * Opens profile editor dialog directly for the specified profile + */ +const editCommand: SlashCommand = { + name: 'edit', + description: 'edit a specific profile', + kind: CommandKind.BUILT_IN, + schema: profileEditSchema, + action: async ( + _context: CommandContext, + args: string, + ): Promise => { + const trimmedArgs = args?.trim(); - const profileList = profiles.map((name) => ` • ${name}`).join('\n'); + if (!trimmedArgs) { return { type: 'message', - messageType: 'info', - content: `Saved profiles:\n${profileList}`, + messageType: 'error', + content: 'Usage: /profile edit ', }; - } catch (error) { + } + + // Extract profile name - handle quoted names + const profileNameMatch = trimmedArgs.match(/^"([^"]+)"$/); + const profileName = profileNameMatch ? profileNameMatch[1] : trimmedArgs; + + if (!profileName) { return { type: 'message', messageType: 'error', - content: `Failed to list profiles: ${error instanceof Error ? error.message : String(error)}`, + content: 'Usage: /profile edit ', }; } + + // Verify profile exists + try { + const profiles = await listProfiles(); + if (!profiles.includes(profileName)) { + return { + type: 'message', + messageType: 'error', + content: `Profile '${profileName}' not found. Use /profile list to see available profiles.`, + }; + } + } catch { + // Continue anyway, the dialog will show the error + } + + return { + type: 'dialog', + dialog: 'profileEditor', + dialogData: { profileName }, + }; }, }; @@ -944,6 +1058,8 @@ export const profileCommand: SlashCommand = { deleteCommand, setDefaultCommand, listCommand, + showCommand, + editCommand, ], action: async ( _context: CommandContext, @@ -956,6 +1072,8 @@ export const profileCommand: SlashCommand = { /profile save loadbalancer [...] - Save a load balancer profile /profile load - Load a saved profile + /profile show - View details of a specific profile + /profile edit - Edit a specific profile /profile create - Interactive wizard to create a profile /profile delete - Delete a saved profile /profile set-default - Set profile to load on startup (or "none") diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index b9d95b650..86e4bfd3a 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -141,6 +141,14 @@ export interface LoggingDialogData { entries: unknown[]; } +/** + * Type-safe dialog data for profile dialogs. + */ +export interface ProfileDialogData { + /** Name of the profile to display/edit */ + profileName?: string; +} + /** All supported dialog types */ export type DialogType = | 'auth' @@ -155,12 +163,17 @@ export type DialogType = | 'loadProfile' | 'createProfile' | 'saveProfile' - | 'subagent'; + | 'subagent' + | 'profileList' + | 'profileDetail' + | 'profileEditor'; /** Map dialog types to their associated data types for type-safe access */ export interface DialogDataMap { subagent: SubagentDialogData; logging: LoggingDialogData; + profileDetail: ProfileDialogData; + profileEditor: ProfileDialogData; } /** @@ -174,9 +187,10 @@ export interface OpenDialogActionReturn { * Dialog-specific data. Type depends on dialog: * - 'subagent': SubagentDialogData * - 'logging': LoggingDialogData + * - 'profileDetail'/'profileEditor': ProfileDialogData * - others: undefined */ - dialogData?: SubagentDialogData | LoggingDialogData; + dialogData?: SubagentDialogData | LoggingDialogData | ProfileDialogData; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 35c84a784..4ccaa562e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -22,7 +22,11 @@ import { ProviderDialog } from './ProviderDialog.js'; import { ProviderModelDialog } from './ProviderModelDialog.js'; import { LoadProfileDialog } from './LoadProfileDialog.js'; import { ProfileCreateWizard } from './ProfileCreateWizard/index.js'; +import { ProfileListDialog } from './ProfileListDialog.js'; +import { ProfileDetailDialog } from './ProfileDetailDialog.js'; +import { ProfileInlineEditor } from './ProfileInlineEditor.js'; import { ToolsDialog } from './ToolsDialog.js'; +import type { Profile, Config } from '@vybestack/llxprt-code-core'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; // import { ProQuotaDialog } from './ProQuotaDialog.js'; // TODO: Not yet ported from upstream @@ -34,7 +38,6 @@ import { SubagentView } from './SubagentManagement/types.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; -import type { Config } from '@vybestack/llxprt-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; // import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; // TODO: Not yet ported from upstream @@ -269,6 +272,58 @@ export const DialogManager = ({ ); } + if (uiState.isProfileListDialogOpen) { + return ( + + + + ); + } + if (uiState.isProfileDetailDialogOpen) { + return ( + + + + ); + } + if (uiState.isProfileEditorDialogOpen && uiState.selectedProfileData) { + return ( + + void + } + onCancel={uiActions.closeProfileEditor} + error={uiState.profileDialogError ?? undefined} + /> + + ); + } if (uiState.isToolsDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/ProfileDetailDialog.tsx b/packages/cli/src/ui/components/ProfileDetailDialog.tsx new file mode 100644 index 000000000..a40019ad6 --- /dev/null +++ b/packages/cli/src/ui/components/ProfileDetailDialog.tsx @@ -0,0 +1,343 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { Box, Text } from 'ink'; +import { SemanticColors } from '../colors.js'; +import { useResponsive } from '../hooks/useResponsive.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import type { Profile } from '@vybestack/llxprt-code-core'; + +interface ProfileDetailDialogProps { + profileName: string; + profile: Profile | null; + onClose: () => void; + onLoad: (profileName: string) => void; + onDelete: (profileName: string) => void; + onSetDefault: (profileName: string) => void; + onEdit: (profileName: string) => void; + isLoading?: boolean; + isDefault?: boolean; + isActive?: boolean; + error?: string; +} + +/** + * Allowlist of ephemeralSettings keys that are safe to display. + * Any key NOT in this set will be hidden to prevent accidental secret leakage. + */ +const SAFE_EPHEMERAL_KEYS = new Set([ + 'baseurl', + 'endpoint', + 'url', + 'timeout', + 'maxretries', + 'retries', + 'region', + 'debug', + 'loglevel', + 'version', + 'apiversion', + 'organization', + 'orgid', + 'project', + 'projectid', + 'maxtokens', + 'temperature', + 'topp', + 'topk', + 'stream', + 'safetysettings', +]); + +// Type guard for load balancer profile +function isLoadBalancerProfile(profile: Profile): profile is Profile & { + type: 'loadbalancer'; + profiles: string[]; + policy: string; +} { + const p = profile as unknown as Record; + return ( + profile.type === 'loadbalancer' && + Array.isArray(p.profiles) && + typeof p.policy === 'string' + ); +} + +export const ProfileDetailDialog: React.FC = ({ + profileName, + profile, + onClose, + onLoad, + onDelete, + onSetDefault, + onEdit, + isLoading = false, + isDefault = false, + isActive = false, + error, +}) => { + const { isNarrow, width } = useResponsive(); + const [confirmDelete, setConfirmDelete] = useState(false); + + useKeypress( + (key) => { + if (key.name === 'escape') { + if (confirmDelete) { + setConfirmDelete(false); + } else { + return onClose(); + } + } + + // On error / missing profile screens, only Esc should do anything. + if (error || !profile) { + return; + } + + if (confirmDelete) { + if (key.sequence === 'y' || key.sequence === 'Y') { + onDelete(profileName); + } else if (key.sequence === 'n' || key.sequence === 'N') { + setConfirmDelete(false); + } + return; + } + + // Quick actions + if (key.sequence === 'l') { + return onLoad(profileName); + } + if (key.sequence === 'e') { + return onEdit(profileName); + } + if (key.sequence === 'd') { + setConfirmDelete(true); + } + if (key.sequence === 's') { + return onSetDefault(profileName); + } + }, + { isActive: !isLoading }, + ); + + if (isLoading) { + return ( + + Loading profile... + + ); + } + + if (error) { + return ( + + + Error Loading Profile + + {error} + + + Press Esc to go back + + + + ); + } + + if (!profile) { + return ( + + + Profile not found: {profileName} + + Press Esc to go back + + ); + } + + // Render JSON config in a readable format + const renderConfig = () => { + if (isLoadBalancerProfile(profile)) { + return ( + + + Type: + Load Balancer + + + Policy: + {profile.policy} + + + Member Profiles: + {profile.profiles.map((p: string) => ( + + {' '}- {p} + + ))} + + + ); + } + + // Standard profile + return ( + + + Type: + Standard + + + Provider: + {profile.provider} + + + Model: + {profile.model} + + + {/* Model Parameters */} + {profile.modelParams && Object.keys(profile.modelParams).length > 0 && ( + + Model Parameters: + {Object.entries(profile.modelParams).map(([key, value]) => ( + + {' '} + {key}: {JSON.stringify(value)} + + ))} + + )} + + {/* Ephemeral Settings - show only allowlisted safe keys */} + {profile.ephemeralSettings && ( + + Settings: + {Object.entries(profile.ephemeralSettings) + .filter(([key]) => SAFE_EPHEMERAL_KEYS.has(key.toLowerCase())) + .filter(([, value]) => value !== undefined && value !== null) + .slice(0, 10) // Limit displayed settings + .map(([key, value]) => ( + + {' '} + {key}: {JSON.stringify(value)} + + ))} + + )} + + {/* Auth Config */} + {profile.auth && ( + + Authentication: + + {' '}Type: {profile.auth.type} + + {profile.auth.buckets && profile.auth.buckets.length > 0 && ( + + {' '}Buckets: {profile.auth.buckets.join(', ')} + + )} + + )} + + ); + }; + + // Delete confirmation overlay + if (confirmDelete) { + return ( + + + Delete Profile? + + + + Are you sure you want to delete "{profileName}"? + + + + This action cannot be undone. + + + + Press y to confirm, n or Esc to cancel + + + + ); + } + + const dialogWidth = isNarrow ? undefined : Math.min(width, 80); + + return ( + + {/* Header */} + + + {profileName} + + {isActive && ( + (Active) + )} + {isDefault && ( + (Default) + )} + + + {/* Config display */} + {renderConfig()} + + {/* Actions */} + + + Actions: l=load{' '} + e=edit{' '} + d=delete{' '} + s=set-default{' '} + Esc=back + + + + ); +}; diff --git a/packages/cli/src/ui/components/ProfileInlineEditor.tsx b/packages/cli/src/ui/components/ProfileInlineEditor.tsx new file mode 100644 index 000000000..f4050d92a --- /dev/null +++ b/packages/cli/src/ui/components/ProfileInlineEditor.tsx @@ -0,0 +1,358 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { SemanticColors } from '../colors.js'; +import { useResponsive } from '../hooks/useResponsive.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import type { Profile } from '@vybestack/llxprt-code-core'; + +interface ProfileInlineEditorProps { + profileName: string; + profile: Profile; + onSave: (profileName: string, updatedProfile: Profile) => void; + onCancel: () => void; + error?: string; +} + +/** + * Validates a profile structure. Returns error message or null if valid. + */ +function validateProfile(profile: unknown): string | null { + if (typeof profile !== 'object' || profile === null) { + return 'Invalid profile: must be an object'; + } + + const p = profile as Record; + + if (!('version' in p) || typeof p.version !== 'string') { + return 'Missing or invalid version'; + } + + if (!('type' in p) || typeof p.type !== 'string') { + return 'Missing or invalid type'; + } + + if (p.type === 'standard') { + if (!('provider' in p) || typeof p.provider !== 'string' || !p.provider) { + return 'Standard profile requires provider'; + } + if (!('model' in p) || typeof p.model !== 'string' || !p.model) { + return 'Standard profile requires model'; + } + } else if (p.type === 'loadbalancer') { + if (!('profiles' in p) || !Array.isArray(p.profiles)) { + return 'Load balancer requires profiles array'; + } + if (p.profiles.length === 0) { + return 'Load balancer requires at least one profile'; + } + if (!p.profiles.every((item) => typeof item === 'string')) { + return 'Profiles must be strings'; + } + if (!('policy' in p) || typeof p.policy !== 'string' || !p.policy) { + return 'Load balancer requires policy'; + } + } else { + return `Unknown profile type: ${p.type}`; + } + + return null; +} + +// Simple JSON editor that allows line-by-line editing +export const ProfileInlineEditor: React.FC = ({ + profileName, + profile, + onSave, + onCancel, + error: externalError, +}) => { + const { width } = useResponsive(); + + // Convert profile to formatted JSON lines + const formatProfile = (p: Profile): string[] => { + const json = JSON.stringify(p, null, 2); + return json.split('\n'); + }; + + const [lines, setLines] = useState(() => formatProfile(profile)); + const [cursorLine, setCursorLine] = useState(0); + const [isEditing, setIsEditing] = useState(false); + const [editBuffer, setEditBuffer] = useState(''); + const [validationError, setValidationError] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + + // Visible window for scrolling + const maxVisibleLines = 15; + const [scrollOffset, setScrollOffset] = useState(0); + + // Reset editor when a different profile is loaded into the dialog. + useEffect(() => { + setLines(formatProfile(profile)); + setCursorLine(0); + setScrollOffset(0); + setIsEditing(false); + setEditBuffer(''); + setValidationError(null); + setHasChanges(false); + }, [profileName, profile]); + + // Ensure cursor line is visible + useEffect(() => { + if (cursorLine < scrollOffset) { + setScrollOffset(cursorLine); + } else if (cursorLine >= scrollOffset + maxVisibleLines) { + setScrollOffset(cursorLine - maxVisibleLines + 1); + } + }, [cursorLine, scrollOffset, maxVisibleLines]); + + // Validate JSON on changes + const validateJson = useMemo(() => { + try { + const jsonString = lines.join('\n'); + JSON.parse(jsonString); + return null; + } catch (e) { + return e instanceof Error ? e.message : 'Invalid JSON'; + } + }, [lines]); + + // Clear stale validation errors when JSON becomes valid + useEffect(() => { + if (!validateJson) { + setValidationError(null); + } + }, [validateJson]); + + useKeypress( + (key) => { + if (isEditing) { + // Edit mode + if (key.name === 'escape') { + // Cancel line edit + setIsEditing(false); + setEditBuffer(''); + return; + } + if (key.name === 'return') { + // Commit line edit + const newLines = [...lines]; + newLines[cursorLine] = editBuffer; + setLines(newLines); + setIsEditing(false); + setEditBuffer(''); + setHasChanges(true); + return; + } + if (key.name === 'backspace' || key.name === 'delete') { + setEditBuffer((prev) => prev.slice(0, -1)); + return; + } + if ( + key.sequence && + typeof key.sequence === 'string' && + !key.ctrl && + !key.meta && + key.insertable !== false + ) { + setEditBuffer((prev) => prev + key.sequence); + return; + } + return; + } + + // Navigation mode + if (key.name === 'escape') { + onCancel(); + return; + } + + // Save + if (key.ctrl && key.name === 's') { + if (validateJson) { + setValidationError(validateJson); + return; + } + try { + const updatedProfile = JSON.parse(lines.join('\n')); + // Comprehensive profile validation + const profileError = validateProfile(updatedProfile); + if (profileError) { + setValidationError(profileError); + return; + } + onSave(profileName, updatedProfile as Profile); + } catch (e) { + setValidationError(e instanceof Error ? e.message : 'Invalid JSON'); + } + return; + } + + // Navigation + if (key.name === 'up' || key.sequence === 'k') { + setCursorLine((prev) => Math.max(0, prev - 1)); + return; + } + if (key.name === 'down' || key.sequence === 'j') { + setCursorLine((prev) => Math.min(lines.length - 1, prev + 1)); + return; + } + + // Enter edit mode + if (key.name === 'return' || key.sequence === 'e') { + setIsEditing(true); + setEditBuffer(lines[cursorLine]); + setValidationError(null); + return; + } + + // Page up/down + if (key.name === 'pageup') { + setCursorLine((prev) => Math.max(0, prev - maxVisibleLines)); + return; + } + if (key.name === 'pagedown') { + setCursorLine((prev) => + Math.min(lines.length - 1, prev + maxVisibleLines), + ); + return; + } + + // Home/End + if (key.sequence === 'g') { + setCursorLine(0); + return; + } + if (key.sequence === 'G') { + setCursorLine(lines.length - 1); + return; + } + }, + { isActive: true }, + ); + + const visibleLines = lines.slice( + scrollOffset, + scrollOffset + maxVisibleLines, + ); + const dialogWidth = Math.min(width, 90); + + return ( + + {/* Header */} + + + Edit: {profileName} + + {hasChanges && ( + (modified) + )} + + + {/* Error display */} + {(validationError || externalError || validateJson) && ( + + + Error: {validationError || externalError || validateJson} + + + )} + + {/* Editor area */} + + {visibleLines.map((line, idx) => { + const actualLine = scrollOffset + idx; + const isCurrentLine = actualLine === cursorLine; + + return ( + + {/* Line number */} + + + {String(actualLine + 1).padStart(3, ' ')} + + + + {/* Line content */} + {isEditing && isCurrentLine ? ( + + + {editBuffer} + + + + ) : ( + + {isCurrentLine ? '› ' : ' '} + {line} + + )} + + ); + })} + + + {/* Scroll indicator */} + {lines.length > maxVisibleLines && ( + + + Lines {scrollOffset + 1}- + {Math.min(scrollOffset + maxVisibleLines, lines.length)} of{' '} + {lines.length} + + + )} + + {/* Instructions */} + + {isEditing ? ( + + Editing line {cursorLine + 1}. Enter=commit, Esc=cancel + + ) : ( + + ↑/↓=navigate, Enter/e=edit line, Ctrl+S=save, Esc=cancel + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/ProfileListDialog.tsx b/packages/cli/src/ui/components/ProfileListDialog.tsx new file mode 100644 index 000000000..61a6c6396 --- /dev/null +++ b/packages/cli/src/ui/components/ProfileListDialog.tsx @@ -0,0 +1,369 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { SemanticColors } from '../colors.js'; +import { useResponsive } from '../hooks/useResponsive.js'; +import { truncateEnd } from '../utils/responsive.js'; +import { useKeypress } from '../hooks/useKeypress.js'; + +export interface ProfileListItem { + name: string; + type: 'standard' | 'loadbalancer'; + provider?: string; + model?: string; + isDefault?: boolean; + isActive?: boolean; + loadError?: boolean; +} + +interface ProfileListDialogProps { + profiles: ProfileListItem[]; + onSelect: (profileName: string) => void; + onClose: () => void; + onViewDetail: (profileName: string) => void; + isLoading?: boolean; + defaultProfileName?: string; + activeProfileName?: string; +} + +export const ProfileListDialog: React.FC = ({ + profiles, + onSelect, + onClose, + onViewDetail, + isLoading = false, + defaultProfileName, + activeProfileName, +}) => { + const { isNarrow, isWide, width } = useResponsive(); + const [searchTerm, setSearchTerm] = useState(''); + const [isSearching, setIsSearching] = useState(true); // Start in search mode + const [index, setIndex] = useState(0); + + // Filter profiles based on search term + const filteredProfiles = useMemo( + () => + profiles.filter((p) => + p.name.toLowerCase().includes(searchTerm.toLowerCase()), + ), + [profiles, searchTerm], + ); + + // Reset index when search term changes + React.useEffect(() => { + setIndex(0); + }, [searchTerm]); + + // Clamp index when the underlying list changes. + React.useEffect(() => { + setIndex((prev) => { + if (filteredProfiles.length === 0) return 0; + return Math.min(prev, filteredProfiles.length - 1); + }); + }, [filteredProfiles.length]); + + const columns = isNarrow ? 1 : isWide ? 3 : 2; + const longest = filteredProfiles.reduce( + (len, p) => Math.max(len, p.name.length + 10), // account for indicators + 0, + ); + const colWidth = isWide + ? Math.max(longest + 4, 35) + : Math.max(longest + 4, 25); + const rows = Math.ceil(filteredProfiles.length / columns); + + const move = (delta: number) => { + if (filteredProfiles.length === 0) { + setIndex(0); + return; + } + let next = index + delta; + if (next < 0) next = 0; + if (next >= filteredProfiles.length) next = filteredProfiles.length - 1; + setIndex(next); + }; + + useKeypress( + (key) => { + if (key.name === 'escape') { + if (isSearching && searchTerm.length > 0) { + setSearchTerm(''); + } else { + return onClose(); + } + } + + if (isSearching || isNarrow) { + if (key.name === 'return') { + if (filteredProfiles.length > 0) { + if (isNarrow) { + return onViewDetail(filteredProfiles[index].name); + } + setIsSearching(false); + } + } else if (key.name === 'tab' && !isNarrow) { + setIsSearching(false); + } else if (key.name === 'backspace' || key.name === 'delete') { + setSearchTerm((prev) => prev.slice(0, -1)); + } else if ( + key.sequence && + typeof key.sequence === 'string' && + !key.ctrl && + !key.meta && + key.insertable !== false + ) { + setSearchTerm((prev) => prev + key.sequence); + } + } else { + // Navigation mode + if (key.name === 'return' && filteredProfiles.length > 0) { + return onViewDetail(filteredProfiles[index].name); + } + if (key.name === 'tab') { + setIsSearching(true); + } + if (filteredProfiles.length === 0) { + return; + } + // Quick actions + if (key.sequence === 'l' && filteredProfiles.length > 0) { + return onSelect(filteredProfiles[index].name); + } + // Navigation + if (key.name === 'left') move(-1); + if (key.name === 'right') move(1); + if (key.name === 'up') move(-columns); + if (key.name === 'down') move(columns); + // Vim-style navigation + if (key.sequence === 'j') move(columns); + if (key.sequence === 'k') move(-columns); + if (key.sequence === 'h') move(-1); + } + }, + { isActive: !isLoading }, + ); + + const renderItem = (profile: ProfileListItem, i: number) => { + const selected = i === index && (!isSearching || isNarrow); + const isActiveProfile = profile.name === activeProfileName; + const isDefaultProfile = profile.name === defaultProfileName; + + // Build indicators + let indicators = ''; + if (isActiveProfile) indicators += '*'; + if (isDefaultProfile) indicators += 'D'; + if (profile.type === 'loadbalancer') indicators += 'LB'; + + const indicatorText = indicators ? ` [${indicators}]` : ''; + + // Truncate name if needed + const maxNameLen = colWidth - 6 - indicatorText.length; + const displayName = isWide + ? profile.name + : profile.name.length > maxNameLen + ? truncateEnd(profile.name, maxNameLen) + : profile.name; + + return ( + + + {selected ? '● ' : '○ '} + {displayName} + {indicatorText && ( + {indicatorText} + )} + + + ); + }; + + const grid: React.ReactNode[] = []; + for (let r = 0; r < rows; r++) { + const rowItems: React.ReactNode[] = []; + for (let c = 0; c < columns; c++) { + const i = r * columns + c; + if (i < filteredProfiles.length) { + rowItems.push(renderItem(filteredProfiles[i], i)); + } + } + grid.push({rowItems}); + } + + if (isLoading) { + return ( + + Loading profiles... + + ); + } + + if (profiles.length === 0) { + return ( + + + No saved profiles found. Use /profile save model <name> to + create one. + + Press Esc to close + + ); + } + + const renderContent = () => { + if (isNarrow) { + return ( + + + Profiles + + + {/* Search input */} + + + Search: + + {searchTerm} + + + + Type to filter, Enter for details, Esc to cancel + + + {/* Profile count */} + + {filteredProfiles.length} profiles{searchTerm && ' found'} + + + {/* Results */} + {filteredProfiles.length > 0 ? ( + grid + ) : ( + + + No profiles match "{searchTerm}" + + + )} + + ); + } + + return ( + + {/* Title */} + + Profile List + + + {/* Search */} + + + Search:{' '} + {isSearching && } + + {searchTerm} + + {' '} + (press Tab to {isSearching ? 'navigate' : 'search'}) Found{' '} + {filteredProfiles.length} profiles + + + + {/* Body - Grid results */} + {filteredProfiles.length > 0 ? ( + grid + ) : ( + + + No profiles match "{searchTerm}" + + + )} + + {/* Current selection */} + {filteredProfiles.length > 0 && !isSearching && ( + + + Selected: {filteredProfiles[index].name} + {filteredProfiles[index].provider && ( + + {' '} + ({filteredProfiles[index].provider} + {filteredProfiles[index].model && + ` / ${filteredProfiles[index].model}`} + ) + + )} + + + )} + + {/* Legend */} + + + Legend: * = active, D = default, LB = load balancer + + + + {/* Space */} + + + {/* Controls */} + + Controls: ↑↓←→ Navigate [Enter] Details [l] Load [Esc] Close + + + ); + }; + + return isNarrow ? ( + + {renderContent()} + + ) : ( + + {renderContent()} + + ); +}; diff --git a/packages/cli/src/ui/components/ProviderDialog.tsx b/packages/cli/src/ui/components/ProviderDialog.tsx index bb12046e5..86de84c69 100644 --- a/packages/cli/src/ui/components/ProviderDialog.tsx +++ b/packages/cli/src/ui/components/ProviderDialog.tsx @@ -97,7 +97,7 @@ export const ProviderDialog: React.FC = ({ typeof key.sequence === 'string' && !key.ctrl && !key.meta && - key.insertable + key.insertable !== false ) { setSearchTerm((prev) => prev + key.sequence); } diff --git a/packages/cli/src/ui/components/ProviderModelDialog.tsx b/packages/cli/src/ui/components/ProviderModelDialog.tsx index 6669f4245..c6dc1d972 100644 --- a/packages/cli/src/ui/components/ProviderModelDialog.tsx +++ b/packages/cli/src/ui/components/ProviderModelDialog.tsx @@ -150,7 +150,7 @@ export const ProviderModelDialog: React.FC = ({ typeof key.sequence === 'string' && !key.ctrl && !key.meta && - key.insertable + key.insertable !== false ) { setSearchTerm((prev) => prev + key.sequence); } diff --git a/packages/cli/src/ui/contexts/RuntimeContext.tsx b/packages/cli/src/ui/contexts/RuntimeContext.tsx index dc963d3b8..4bdc1d4c3 100644 --- a/packages/cli/src/ui/contexts/RuntimeContext.tsx +++ b/packages/cli/src/ui/contexts/RuntimeContext.tsx @@ -26,6 +26,7 @@ import { getCliRuntimeServices, getEphemeralSetting, getEphemeralSettings, + getProfileByName, getRuntimeDiagnosticsSnapshot, listAvailableModels, listProviders, @@ -77,6 +78,7 @@ const runtimeFunctions = { loadProfileByName, deleteProfileByName, listSavedProfiles, + getProfileByName, setDefaultProfileName, updateActiveProviderBaseUrl, updateActiveProviderApiKey, diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index b5b8a1941..2b88f8b0d 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -78,6 +78,21 @@ export interface UIActions { openCreateProfileDialog: () => void; exitCreateProfileDialog: () => void; + // Profile management dialogs + openProfileListDialog: () => void; + closeProfileListDialog: () => void; + viewProfileDetail: (profileName: string, openedDirectly?: boolean) => void; + closeProfileDetailDialog: () => void; + loadProfileFromDetail: (profileName: string) => void; + deleteProfileFromDetail: (profileName: string) => void; + setProfileAsDefault: (profileName: string) => void; + openProfileEditor: (profileName: string, openedDirectly?: boolean) => void; + closeProfileEditor: () => void; + saveProfileFromEditor: ( + profileName: string, + updatedProfile: unknown, + ) => Promise; + // Tools dialog openToolsDialog: (action: 'enable' | 'disable') => void; handleToolsSelect: (tool: string) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index d6352fa5b..d0ec3ec14 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -67,6 +67,9 @@ export interface UIState { isProviderModelDialogOpen: boolean; isLoadProfileDialogOpen: boolean; isCreateProfileDialogOpen: boolean; + isProfileListDialogOpen: boolean; + isProfileDetailDialogOpen: boolean; + isProfileEditorDialogOpen: boolean; isToolsDialogOpen: boolean; isFolderTrustDialogOpen: boolean; showWorkspaceMigrationDialog: boolean; @@ -90,6 +93,22 @@ export interface UIState { subagentDialogInitialView?: SubagentView; subagentDialogInitialName?: string; + // Profile management dialog data + profileListItems: Array<{ + name: string; + type: 'standard' | 'loadbalancer'; + provider?: string; + model?: string; + isDefault?: boolean; + isActive?: boolean; + }>; + selectedProfileName: string | null; + selectedProfileData: unknown | null; + defaultProfileName: string | null; + activeProfileName: string | null; + profileDialogError: string | null; + profileDialogLoading: boolean; + // Confirmation requests shellConfirmationRequest: ShellConfirmationRequest | null; confirmationRequest: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f5d5ae743..11ce05de0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -68,6 +68,9 @@ interface SlashCommandProcessorActions { openProviderDialog: () => void; openLoadProfileDialog: () => void; openCreateProfileDialog: () => void; + openProfileListDialog: () => void; + viewProfileDetail: (profileName: string, openedDirectly?: boolean) => void; + openProfileEditor: (profileName: string, openedDirectly?: boolean) => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; @@ -500,6 +503,48 @@ export const useSlashCommandProcessor = ( case 'createProfile': actions.openCreateProfileDialog(); return { type: 'handled' }; + case 'profileList': + slashCommandLogger.log( + () => 'opening profileList dialog', + ); + actions.openProfileListDialog(); + return { type: 'handled' }; + case 'profileDetail': + if ( + result.dialogData && + typeof result.dialogData === 'object' && + 'profileName' in result.dialogData && + typeof (result.dialogData as { profileName: unknown }) + .profileName === 'string' + ) { + const profileName = ( + result.dialogData as { profileName: string } + ).profileName; + slashCommandLogger.log( + () => `opening profileDetail for ${profileName}`, + ); + // Pass true for openedDirectly since this came from /profile show + actions.viewProfileDetail(profileName, true); + } + return { type: 'handled' }; + case 'profileEditor': + if ( + result.dialogData && + typeof result.dialogData === 'object' && + 'profileName' in result.dialogData && + typeof (result.dialogData as { profileName: unknown }) + .profileName === 'string' + ) { + const profileName = ( + result.dialogData as { profileName: string } + ).profileName; + slashCommandLogger.log( + () => `opening profileEditor for ${profileName}`, + ); + // Pass true for openedDirectly since this came from /profile edit + actions.openProfileEditor(profileName, true); + } + return { type: 'handled' }; case 'saveProfile': return { type: 'handled' }; case 'subagent': { diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index d848d184a..f82df59df 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -63,6 +63,9 @@ describe('useEditorSettings', () => { privacy: false, loadProfile: false, createProfile: false, + profileList: false, + profileDetail: false, + profileEditor: false, tools: false, oauthCode: false, }, diff --git a/packages/cli/src/ui/hooks/useProfileManagement.ts b/packages/cli/src/ui/hooks/useProfileManagement.ts new file mode 100644 index 000000000..aa901e26e --- /dev/null +++ b/packages/cli/src/ui/hooks/useProfileManagement.ts @@ -0,0 +1,423 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useState } from 'react'; +import { MessageType } from '../types.js'; +import { useAppDispatch } from '../contexts/AppDispatchContext.js'; +import { AppState } from '../reducers/appReducer.js'; +import { useRuntimeApi } from '../contexts/RuntimeContext.js'; +import type { Profile } from '@vybestack/llxprt-code-core'; +import type { ProfileListItem } from '../components/ProfileListDialog.js'; +import { ProfileManager, DebugLogger } from '@vybestack/llxprt-code-core'; + +const debug = new DebugLogger('llxprt:ui:useProfileManagement'); + +/** + * Validates a profile before saving. Returns error message or null if valid. + */ +function validateProfileForSave(profile: unknown): string | null { + if (typeof profile !== 'object' || profile === null) { + return 'Invalid profile: must be an object'; + } + + const p = profile as Record; + + if (!('version' in p) || typeof p.version !== 'string') { + return 'Invalid profile: missing or invalid version'; + } + + if (!('type' in p) || typeof p.type !== 'string') { + return 'Invalid profile: missing or invalid type'; + } + + // Type-specific validation + if (p.type === 'standard') { + if (!('provider' in p) || typeof p.provider !== 'string' || !p.provider) { + return 'Standard profile requires a provider'; + } + if (!('model' in p) || typeof p.model !== 'string' || !p.model) { + return 'Standard profile requires a model'; + } + } else if (p.type === 'loadbalancer') { + if (!('profiles' in p) || !Array.isArray(p.profiles)) { + return 'Load balancer profile requires a profiles array'; + } + if (p.profiles.length === 0) { + return 'Load balancer profile requires at least one profile'; + } + if (!p.profiles.every((item) => typeof item === 'string')) { + return 'Load balancer profiles must be strings'; + } + if (!('policy' in p) || typeof p.policy !== 'string' || !p.policy) { + return 'Load balancer profile requires a policy'; + } + } else { + return `Unknown profile type: ${p.type}`; + } + + return null; +} + +interface UseProfileManagementParams { + addMessage: (msg: { + type: MessageType; + content: string; + timestamp: Date; + }) => void; + appState: AppState; +} + +export const useProfileManagement = ({ + addMessage, + appState, +}: UseProfileManagementParams) => { + const appDispatch = useAppDispatch(); + const runtime = useRuntimeApi(); + + // Dialog visibility states + const showListDialog = appState.openDialogs.profileList; + const showDetailDialog = appState.openDialogs.profileDetail; + const showEditorDialog = appState.openDialogs.profileEditor; + + // Data states + const [profiles, setProfiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedProfileName, setSelectedProfileName] = useState( + null, + ); + const [selectedProfile, setSelectedProfile] = useState(null); + const [defaultProfileName, setDefaultProfileName] = useState( + null, + ); + const [activeProfileName, setActiveProfileName] = useState( + null, + ); + const [profileError, setProfileError] = useState(null); + // Track if detail was opened directly (vs from list) + const [detailOpenedDirectly, setDetailOpenedDirectly] = useState(false); + // Track if editor was opened directly (vs from detail) + const [editorOpenedDirectly, setEditorOpenedDirectly] = useState(false); + + // Load profiles list + const loadProfiles = useCallback(async () => { + setIsLoading(true); + setProfileError(null); + + try { + const profileNames = await runtime.listSavedProfiles(); + + // Get additional profile info (type, provider, model) + const profileItems: ProfileListItem[] = await Promise.all( + profileNames.map(async (name) => { + try { + const profile = await runtime.getProfileByName(name); + const isLB = profile.type === 'loadbalancer'; + return { + name, + type: isLB ? 'loadbalancer' : 'standard', + provider: isLB ? undefined : profile.provider, + model: isLB ? undefined : profile.model, + } as ProfileListItem; + } catch { + // If we can't load the profile, return with error indicator + return { + name, + type: 'standard', + loadError: true, + } as ProfileListItem; + } + }), + ); + + setProfiles(profileItems); + + // Try to get default profile name from settings + try { + const services = runtime.getCliRuntimeServices(); + const defaultName = services.settingsService.get('defaultProfile') as + | string + | null; + setDefaultProfileName(defaultName ?? null); + } catch { + // Ignore errors getting default profile + } + + // Try to get active profile name + try { + const diagnostics = runtime.getRuntimeDiagnosticsSnapshot(); + const current = diagnostics?.profileName; + setActiveProfileName(current ?? null); + } catch { + // Ignore errors getting active profile + } + } catch (error) { + setProfileError( + error instanceof Error ? error.message : 'Failed to load profiles', + ); + addMessage({ + type: MessageType.ERROR, + content: `Failed to load profiles: ${error instanceof Error ? error.message : 'Unknown error'}`, + timestamp: new Date(), + }); + } finally { + setIsLoading(false); + } + }, [runtime, addMessage]); + + // Open list dialog + const openListDialog = useCallback(async () => { + debug.log(() => 'openListDialog called'); + appDispatch({ type: 'OPEN_DIALOG', payload: 'profileList' }); + debug.log(() => 'dispatched OPEN_DIALOG profileList'); + await loadProfiles(); + debug.log(() => 'loadProfiles completed'); + }, [appDispatch, loadProfiles]); + + // Close list dialog + const closeListDialog = useCallback(() => { + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileList' }); + }, [appDispatch]); + + // View profile detail + // openedDirectly: true when opened via /profile show, false when from list + const viewProfileDetail = useCallback( + async (profileName: string, openedDirectly = false) => { + setSelectedProfileName(profileName); + setSelectedProfile(null); + setProfileError(null); + setIsLoading(true); + setDetailOpenedDirectly(openedDirectly); + + // Close list, open detail + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileList' }); + appDispatch({ type: 'OPEN_DIALOG', payload: 'profileDetail' }); + + try { + const profile = await runtime.getProfileByName(profileName); + setSelectedProfile(profile); + } catch (error) { + setProfileError( + error instanceof Error ? error.message : 'Failed to load profile', + ); + } finally { + setIsLoading(false); + } + }, + [appDispatch, runtime], + ); + + // Close detail dialog + const closeDetailDialog = useCallback(async () => { + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileDetail' }); + setSelectedProfileName(null); + setSelectedProfile(null); + setProfileError(null); + + // If opened directly via /profile show, just close + // If opened from list, go back to list + if (!detailOpenedDirectly) { + appDispatch({ type: 'OPEN_DIALOG', payload: 'profileList' }); + await loadProfiles(); + } + setDetailOpenedDirectly(false); + }, [appDispatch, loadProfiles, detailOpenedDirectly]); + + // Load profile + const loadProfile = useCallback( + async (profileName: string) => { + try { + const result = await runtime.loadProfileByName(profileName); + const extra = (result.infoMessages ?? []) + .map((message: string) => `\n- ${message}`) + .join(''); + addMessage({ + type: MessageType.INFO, + content: `Profile '${profileName}' loaded${extra}`, + timestamp: new Date(), + }); + for (const warning of result.warnings ?? []) { + addMessage({ + type: MessageType.INFO, + content: `\u26A0 ${warning}`, + timestamp: new Date(), + }); + } + // Close all profile dialogs + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileDetail' }); + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileList' }); + } catch (error) { + addMessage({ + type: MessageType.ERROR, + content: `Failed to load profile: ${error instanceof Error ? error.message : String(error)}`, + timestamp: new Date(), + }); + } + }, + [addMessage, appDispatch, runtime], + ); + + // Delete profile + const deleteProfile = useCallback( + async (profileName: string) => { + try { + await runtime.deleteProfileByName(profileName); + addMessage({ + type: MessageType.INFO, + content: `Profile '${profileName}' deleted`, + timestamp: new Date(), + }); + // Close detail dialog and refresh list + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileDetail' }); + appDispatch({ type: 'OPEN_DIALOG', payload: 'profileList' }); + await loadProfiles(); + } catch (error) { + addMessage({ + type: MessageType.ERROR, + content: `Failed to delete profile: ${error instanceof Error ? error.message : String(error)}`, + timestamp: new Date(), + }); + } + }, + [addMessage, appDispatch, runtime, loadProfiles], + ); + + // Set default profile + const setDefault = useCallback( + async (profileName: string) => { + try { + runtime.setDefaultProfileName(profileName); + setDefaultProfileName(profileName); + addMessage({ + type: MessageType.INFO, + content: `Profile '${profileName}' set as default`, + timestamp: new Date(), + }); + } catch (error) { + addMessage({ + type: MessageType.ERROR, + content: `Failed to set default: ${error instanceof Error ? error.message : String(error)}`, + timestamp: new Date(), + }); + } + }, + [addMessage, runtime], + ); + + // Open editor + // openedDirectly: true when opened via /profile edit, false when from detail + const openEditor = useCallback( + async (profileName: string, openedDirectly = false) => { + setSelectedProfileName(profileName); + setEditorOpenedDirectly(openedDirectly); + setProfileError(null); + + // If opened directly, need to load profile data first + if (openedDirectly) { + setIsLoading(true); + try { + const profile = await runtime.getProfileByName(profileName); + setSelectedProfile(profile); + } catch (error) { + setProfileError( + error instanceof Error ? error.message : 'Failed to load profile', + ); + } finally { + setIsLoading(false); + } + } + + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileDetail' }); + appDispatch({ type: 'OPEN_DIALOG', payload: 'profileEditor' }); + }, + [appDispatch, runtime], + ); + + // Close editor + const closeEditor = useCallback(async () => { + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileEditor' }); + + // If opened directly via /profile edit, just close + // If opened from detail, go back to detail (preserving detailOpenedDirectly) + if (!editorOpenedDirectly && selectedProfileName) { + // Preserve the original detailOpenedDirectly state when going back + await viewProfileDetail(selectedProfileName, detailOpenedDirectly); + } else { + setSelectedProfileName(null); + setSelectedProfile(null); + setProfileError(null); + } + setEditorOpenedDirectly(false); + }, [ + appDispatch, + selectedProfileName, + viewProfileDetail, + editorOpenedDirectly, + detailOpenedDirectly, + ]); + + // Save edited profile + const saveProfile = useCallback( + async (profileName: string, updatedProfile: unknown) => { + try { + // Comprehensive validation before save + const validationError = validateProfileForSave(updatedProfile); + if (validationError) { + setProfileError(validationError); + return; + } + + // Use ProfileManager directly to save + const manager = new ProfileManager(); + await manager.saveProfile(profileName, updatedProfile as Profile); + addMessage({ + type: MessageType.INFO, + content: `Profile '${profileName}' saved`, + timestamp: new Date(), + }); + // Close editor and reopen detail + appDispatch({ type: 'CLOSE_DIALOG', payload: 'profileEditor' }); + await viewProfileDetail(profileName, editorOpenedDirectly); + } catch (error) { + setProfileError( + error instanceof Error ? error.message : 'Failed to save profile', + ); + } + }, + [addMessage, appDispatch, viewProfileDetail, editorOpenedDirectly], + ); + + return { + // Dialog states + showListDialog, + showDetailDialog, + showEditorDialog, + + // Data + profiles, + isLoading, + selectedProfileName, + selectedProfile, + defaultProfileName, + activeProfileName, + profileError, + + // List dialog actions + openListDialog, + closeListDialog, + + // Detail dialog actions + viewProfileDetail, + closeDetailDialog, + loadProfile, + deleteProfile, + setDefault, + + // Editor actions + openEditor, + closeEditor, + saveProfile, + }; +}; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 2b2888fae..bf6ec97ac 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -163,6 +163,9 @@ export const DefaultAppLayout = ({ uiState.isProviderModelDialogOpen || uiState.isLoadProfileDialogOpen || uiState.isCreateProfileDialogOpen || + uiState.isProfileListDialogOpen || + uiState.isProfileDetailDialogOpen || + uiState.isProfileEditorDialogOpen || uiState.isToolsDialogOpen || uiState.isLoggingDialogOpen || uiState.isSubagentDialogOpen || diff --git a/packages/cli/src/ui/reducers/appReducer.test.ts b/packages/cli/src/ui/reducers/appReducer.test.ts index aa491b6af..3bb6feead 100644 --- a/packages/cli/src/ui/reducers/appReducer.test.ts +++ b/packages/cli/src/ui/reducers/appReducer.test.ts @@ -26,6 +26,9 @@ describe('appReducer', () => { privacy: false, loadProfile: false, createProfile: false, + profileList: false, + profileDetail: false, + profileEditor: false, tools: false, oauthCode: false, }, @@ -572,6 +575,9 @@ describe('appReducer', () => { privacy: false, loadProfile: false, createProfile: false, + profileList: false, + profileDetail: false, + profileEditor: false, tools: false, oauthCode: false, }, diff --git a/packages/cli/src/ui/reducers/appReducer.ts b/packages/cli/src/ui/reducers/appReducer.ts index 28ee6db9a..9cdf45d18 100644 --- a/packages/cli/src/ui/reducers/appReducer.ts +++ b/packages/cli/src/ui/reducers/appReducer.ts @@ -22,6 +22,9 @@ export type AppAction = | 'privacy' | 'loadProfile' | 'createProfile' + | 'profileList' + | 'profileDetail' + | 'profileEditor' | 'tools' | 'oauthCode'; } @@ -36,6 +39,9 @@ export type AppAction = | 'privacy' | 'loadProfile' | 'createProfile' + | 'profileList' + | 'profileDetail' + | 'profileEditor' | 'tools' | 'oauthCode'; } @@ -55,6 +61,9 @@ export interface AppState { privacy: boolean; loadProfile: boolean; createProfile: boolean; + profileList: boolean; + profileDetail: boolean; + profileEditor: boolean; tools: boolean; oauthCode: boolean; }; @@ -80,6 +89,9 @@ export const initialAppState: AppState = { privacy: false, loadProfile: false, createProfile: false, + profileList: false, + profileDetail: false, + profileEditor: false, tools: false, oauthCode: false, },