claude-code/components/agents/AgentEditor.tsx

178 lines
26 KiB
TypeScript
Raw Normal View History

import chalk from 'chalk';
import figures from 'figures';
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useSetAppState } from 'src/state/AppState.js';
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
import { Box, Text } from '../../ink.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import type { Tools } from '../../Tool.js';
import { type AgentColorName, setAgentColor } from '../../tools/AgentTool/agentColorManager.js';
import { type AgentDefinition, getActiveAgentsFromList, isCustomAgent, isPluginAgent } from '../../tools/AgentTool/loadAgentsDir.js';
import { editFileInEditor } from '../../utils/promptEditor.js';
import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js';
import { ColorPicker } from './ColorPicker.js';
import { ModelSelector } from './ModelSelector.js';
import { ToolSelector } from './ToolSelector.js';
import { getAgentSourceDisplayName } from './utils.js';
type Props = {
agent: AgentDefinition;
tools: Tools;
onSaved: (message: string) => void;
onBack: () => void;
};
type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model';
type SaveChanges = {
tools?: string[];
color?: AgentColorName;
model?: string;
};
export function AgentEditor({
agent,
tools,
onSaved,
onBack
}: Props): React.ReactNode {
const setAppState = useSetAppState();
const [editMode, setEditMode] = useState<EditMode>('menu');
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
const [error, setError] = useState<string | null>(null);
const [selectedColor, setSelectedColor] = useState<AgentColorName | undefined>(agent.color as AgentColorName | undefined);
const handleOpenInEditor = useCallback(async () => {
const filePath = getActualAgentFilePath(agent);
const result = await editFileInEditor(filePath);
if (result.error) {
setError(result.error);
} else {
onSaved(`Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`);
}
}, [agent, onSaved]);
const handleSave = useCallback(async (changes: SaveChanges = {}) => {
const {
tools: newTools,
color: newColor,
model: newModel
} = changes;
const finalColor = newColor ?? selectedColor;
const hasToolsChanged = newTools !== undefined;
const hasModelChanged = newModel !== undefined;
const hasColorChanged = finalColor !== agent.color;
if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) {
return false;
}
try {
// Only custom/plugin agents can be edited
// this is for type safety; the UI shouldn't allow editing otherwise
if (!isCustomAgent(agent) && !isPluginAgent(agent)) {
return false;
}
await updateAgentFile(agent, agent.whenToUse, newTools ?? agent.tools, agent.getSystemPrompt(), finalColor, newModel ?? agent.model);
if (hasColorChanged && finalColor) {
setAgentColor(agent.agentType, finalColor);
}
setAppState(state => {
const allAgents = state.agentDefinitions.allAgents.map(a => a.agentType === agent.agentType ? {
...a,
tools: newTools ?? a.tools,
color: finalColor,
model: newModel ?? a.model
} : a);
return {
...state,
agentDefinitions: {
...state.agentDefinitions,
activeAgents: getActiveAgentsFromList(allAgents),
allAgents
}
};
});
onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save agent');
return false;
}
}, [agent, selectedColor, onSaved, setAppState]);
const menuItems = useMemo(() => [{
label: 'Open in editor',
action: handleOpenInEditor
}, {
label: 'Edit tools',
action: () => setEditMode('edit-tools')
}, {
label: 'Edit model',
action: () => setEditMode('edit-model')
}, {
label: 'Edit color',
action: () => setEditMode('edit-color')
}], [handleOpenInEditor]);
const handleEscape = useCallback(() => {
setError(null);
if (editMode === 'menu') {
onBack();
} else {
setEditMode('menu');
}
}, [editMode, onBack]);
const handleMenuKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'up') {
e.preventDefault();
setSelectedMenuIndex(index => Math.max(0, index - 1));
} else if (e.key === 'down') {
e.preventDefault();
setSelectedMenuIndex(index_0 => Math.min(menuItems.length - 1, index_0 + 1));
} else if (e.key === 'return') {
e.preventDefault();
const selectedItem = menuItems[selectedMenuIndex];
if (selectedItem) {
void selectedItem.action();
}
}
}, [menuItems, selectedMenuIndex]);
useKeybinding('confirm:no', handleEscape, {
context: 'Confirmation'
});
const renderMenu = (): React.ReactNode => <Box flexDirection="column" tabIndex={0} autoFocus onKeyDown={handleMenuKeyDown}>
<Text dimColor>Source: {getAgentSourceDisplayName(agent.source)}</Text>
<Box marginTop={1} flexDirection="column">
{menuItems.map((item, index_1) => <Text key={item.label} color={index_1 === selectedMenuIndex ? 'suggestion' : undefined}>
{index_1 === selectedMenuIndex ? `${figures.pointer} ` : ' '}
{item.label}
</Text>)}
</Box>
{error && <Box marginTop={1}>
<Text color="error">{error}</Text>
</Box>}
</Box>;
switch (editMode) {
case 'menu':
return renderMenu();
case 'edit-tools':
return <ToolSelector tools={tools} initialTools={agent.tools} onComplete={async finalTools => {
setEditMode('menu');
await handleSave({
tools: finalTools
});
}} />;
case 'edit-color':
return <ColorPicker agentName={agent.agentType} currentColor={selectedColor || agent.color as AgentColorName || 'automatic'} onConfirm={async color => {
setSelectedColor(color);
setEditMode('menu');
await handleSave({
color
});
}} />;
case 'edit-model':
return <ModelSelector initialModel={agent.model} onComplete={async model => {
setEditMode('menu');
await handleSave({
model
});
}} />;
default:
return null;
}
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjaGFsayIsImZpZ3VyZXMiLCJSZWFjdCIsInVzZUNhbGxiYWNrIiwidXNlTWVtbyIsInVzZVN0YXRlIiwidXNlU2V0QXBwU3RhdGUiLCJLZXlib2FyZEV2ZW50IiwiQm94IiwiVGV4dCIsInVzZUtleWJpbmRpbmciLCJUb29scyIsIkFnZW50Q29sb3JOYW1lIiwic2V0QWdlbnRDb2xvciIsIkFnZW50RGVmaW5pdGlvbiIsImdldEFjdGl2ZUFnZW50c0Zyb21MaXN0IiwiaXNDdXN0b21BZ2VudCIsImlzUGx1Z2luQWdlbnQiLCJlZGl0RmlsZUluRWRpdG9yIiwiZ2V0QWN0dWFsQWdlbnRGaWxlUGF0aCIsInVwZGF0ZUFnZW50RmlsZSIsIkNvbG9yUGlja2VyIiwiTW9kZWxTZWxlY3RvciIsIlRvb2xTZWxlY3RvciIsImdldEFnZW50U291cmNlRGlzcGxheU5hbWUiLCJQcm9wcyIsImFnZW50IiwidG9vbHMiLCJvblNhdmVkIiwibWVzc2FnZSIsIm9uQmFjayIsIkVkaXRNb2RlIiwiU2F2ZUNoYW5nZXMiLCJjb2xvciIsIm1vZGVsIiwiQWdlbnRFZGl0b3IiLCJSZWFjdE5vZGUiLCJzZXRBcHBTdGF0ZSIsImVkaXRNb2RlIiwic2V0RWRpdE1vZGUiLCJzZWxlY3RlZE1lbnVJbmRleCIsInNldFNlbGVjdGVkTWVudUluZGV4IiwiZXJyb3IiLCJzZXRFcnJvciIsInNlbGVjdGVkQ29sb3IiLCJzZXRTZWxlY3RlZENvbG9yIiwiaGFuZGxlT3BlbkluRWRpdG9yIiwiZmlsZVBhdGgiLCJyZXN1bHQiLCJhZ2VudFR5cGUiLCJoYW5kbGVTYXZlIiwiY2hhbmdlcyIsIm5ld1Rvb2xzIiwibmV3Q29sb3IiLCJuZXdNb2RlbCIsImZpbmFsQ29sb3IiLCJoYXNUb29sc0NoYW5nZWQiLCJ1bmRlZmluZWQiLCJoYXNNb2RlbENoYW5nZWQiLCJoYXNDb2xvckNoYW5nZWQiLCJ3aGVuVG9Vc2UiLCJnZXRTeXN0ZW1Qcm9tcHQiLCJzdGF0ZSIsImFsbEFnZW50cyIsImFnZW50RGVmaW5pdGlvbnMiLCJtYXAiLCJhIiwiYWN0aXZlQWdlbnRzIiwiYm9sZCIsImVyciIsIkVycm9yIiwibWVudUl0ZW1zIiwibGFiZWwiLCJhY3Rpb24iLCJoYW5kbGVFc2NhcGUiLCJoYW5kbGVNZW51S2V5RG93biIsImUiLCJrZXkiLCJwcmV2ZW50RGVmYXVsdCIsImluZGV4IiwiTWF0aCIsIm1heCIsIm1pbiIsImxlbmd0aCIsInNlbGVjdGVkSXRlbSIsImNvbnRleHQiLCJyZW5kZXJNZW51Iiwic291cmNlIiwiaXRlbSIsInBvaW50ZXIiLCJmaW5hbFRvb2xzIl0sInNvdXJjZXMiOlsiQWdlbnRFZGl0b3IudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBjaGFsayBmcm9tICdjaGFsaydcbmltcG9ydCBmaWd1cmVzIGZyb20gJ2ZpZ3VyZXMnXG5pbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUNhbGxiYWNrLCB1c2VNZW1vLCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlU2V0QXBwU3RhdGUgfSBmcm9tICdzcmMvc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IEtleWJvYXJkRXZlbnQgfSBmcm9tICcuLi8uLi9pbmsvZXZlbnRzL2tleWJvYXJkLWV2ZW50LmpzJ1xuaW1wb3J0IHsgQm94LCBUZXh0IH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHsgdXNlS2V5YmluZGluZyB9IGZyb20gJy4uLy4uL2tleWJpbmRpbmdzL3VzZUtleWJpbmRpbmcuanMnXG5pbXBvcnQgdHlwZSB7IFRvb2xzIH0gZnJvbSAnLi4vLi4vVG9vbC5qcydcbmltcG9ydCB7XG4gIHR5cGUgQWdlbnRDb2xvck5hbWUsXG4gIHNldEFnZW50Q29sb3IsXG59IGZyb20gJy4uLy4uL3Rvb2xzL0FnZW50VG9vbC9hZ2VudENvbG9yTWFuYWdlci5qcydcbmltcG9ydCB7XG4gIHR5cGUgQWdlbnREZWZpbml0aW9uLFxuICBnZXRBY3RpdmVBZ2VudHNGcm9tTGlzdCxcbiAgaXNDdXN0b21BZ2VudCxcbiAgaXNQbHVnaW5BZ2VudCxcbn0gZnJvbSAnLi4vLi4vdG9vbHMvQWdlbnRUb29sL2xvYWRBZ2VudHNEaXIuanMnXG5pbXBvcnQgeyBlZGl0RmlsZUluRWRpdG9yIH0gZnJvbSAnLi4vLi4vdXRpbHMvcHJvbXB0RWRpdG9yLmpzJ1xuaW1wb3J0IHsgZ2V0QWN0dWFsQWdlbnRGaWxlUGF0aCwgdXBkYXRlQWdlbnRGaWxlIH0gZnJvbSAnLi9hZ2VudEZpbGVVdGlscy5qcydcbmltcG9ydCB7IENvbG9yUGlja2VyIH0gZnJvbSAnLi9Db2xvclBpY2tlci5qcydcbmltcG9ydCB7IE1vZGVsU2VsZWN0b3IgfSBmcm9tICcuL01vZGVsU2VsZWN0b3IuanMnXG5pbXBvcnQgeyBUb29sU2VsZWN0b3IgfSBmcm9tICcuL1Rvb2xTZWxlY3Rvci5qcydcbmltcG9ydCB7IGdldEFnZW50U291cmNlRGlzcGxheU5hbWUgfSBmcm9tICcuL3V0aWxzLmpzJ1xuXG50eXBlIFByb3BzID0ge1xuICBhZ2VudDogQWdlbnREZWZpbml0aW9uXG4gIHRvb2xzOiBUb29sc1xuICBvblNhdmVkOiAobWVzc2FnZTogc3RyaW5nKSA9PiB2b2lkXG4gIG9uQmFjazogKCkgPT4gdm9pZFxufVxuXG50eXBlIEVkaXRNb2RlID0gJ21lbnUnIHwgJ2VkaXQtdG9vbHMnIHwgJ2VkaXQtY29sb3InIHwgJ2VkaXQtbW9kZWwnXG5cbnR5cGUgU2F2ZUNoYW5nZXMgPSB7XG4gIHRvb2xzPzogc3RyaW5nW11cbiAgY29sb3I/OiBBZ2VudENvbG9yTmFtZVxuICBtb2RlbD86IHN0cmluZ1xufVxuXG5leHBvcnQgZnVuY3Rpb24gQWdlbnRFZGl0b3Ioe1xuICBhZ2VudCxcbiAgdG9vbHMsXG4gIG9uU2F2ZWQsXG4gIG9uQmFjayxcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgY29uc3Qgc2V0QXBwU3RhdGUgPSB1c2VTZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IFtlZGl0TW9kZSwgc2V0RWRpdE1vZGVdID0gdXNlU3RhdGU8RWRpdE1vZGU+KCdtZW51JylcbiAgY29uc3QgW3NlbGVjdGVkTWVudUluZGV4LCBzZXRTZWxlY3RlZE1lbnVJbmRleF0gPSB1c2VTdGF0ZSgwKVxuICBjb25zdCBbZXJyb3IsIHNldEVycm9yXSA9IHVzZVN0YXRlPHN0cmluZyB8IG51bGw+KG51bGwpXG4gIGNvbnN0IFtzZWxlY3RlZENvbG9yLCBzZXRTZWxlY3RlZENvbG9yXSA9IHVzZVN0YXRlPFxuICAgIEFnZW50Q29sb3JOYW1lIHwgdW5kZWZpbmVkXG4gID4oYWdlbnQuY29sb3IgYXMgQWdlbnRDb2xvck5hbWUgfCB1bmRlZmluZWQpXG5cbiAgY29uc3QgaGFuZGxlT3BlbkluRWRpdG9yID0