Files
Maestro/src/renderer/components/AICommandsPanel.tsx
Pedram Amini 41680af24a ## CHANGES
- Cloudflared cache clearing now resets installed and path caches reliably! 🧹
- Cache reset leaves cloudflared path null after failed detection attempts! 🔒
- Claude-code session renames now call `window.maestro.claude.updateSessionName` correctly! ✍️
- RenameSessionModal updated to use Claude rename API for claude-code! 🧠
- Tab switcher now syncs named tabs via Claude session renaming! 🔁
- Remote tab rename persists names through Claude-code integration pathway! 🌐
- Agent sessions API expanded with `setSessionName` for safer naming! 🏷️
- Agent sessions origins fetching added via new `getOrigins` method! 🧭
- AutoRunLightbox now renders via portal with ultra-high z-index! 🪟
- Save buttons now use black text for better contrast readability! 🎨
2025-12-21 20:14:29 -06:00

469 lines
19 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { Plus, Trash2, Edit2, Save, X, Terminal, Lock, ChevronDown, ChevronRight, Variable } from 'lucide-react';
import type { Theme, CustomAICommand } from '../types';
import { TEMPLATE_VARIABLES_GENERAL } from '../utils/templateVariables';
import { useTemplateAutocomplete } from '../hooks/useTemplateAutocomplete';
import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown';
interface AICommandsPanelProps {
theme: Theme;
customAICommands: CustomAICommand[];
setCustomAICommands: (commands: CustomAICommand[]) => void;
}
interface EditingCommand {
id: string;
command: string;
description: string;
prompt: string;
}
export function AICommandsPanel({ theme, customAICommands, setCustomAICommands }: AICommandsPanelProps) {
const [editingCommand, setEditingCommand] = useState<EditingCommand | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [variablesExpanded, setVariablesExpanded] = useState(false);
const [newCommand, setNewCommand] = useState<EditingCommand>({
id: '',
command: '/',
description: '',
prompt: '',
});
// Refs for textareas
const newCommandTextareaRef = useRef<HTMLTextAreaElement>(null);
const editCommandTextareaRef = useRef<HTMLTextAreaElement>(null);
// Template autocomplete for new command prompt
const {
autocompleteState: newAutocompleteState,
handleKeyDown: handleNewAutocompleteKeyDown,
handleChange: handleNewAutocompleteChange,
selectVariable: selectNewVariable,
autocompleteRef: newAutocompleteRef,
} = useTemplateAutocomplete({
textareaRef: newCommandTextareaRef,
value: newCommand.prompt,
onChange: (value) => setNewCommand({ ...newCommand, prompt: value }),
});
// Template autocomplete for edit command prompt
const {
autocompleteState: editAutocompleteState,
handleKeyDown: handleEditAutocompleteKeyDown,
handleChange: handleEditAutocompleteChange,
selectVariable: selectEditVariable,
autocompleteRef: editAutocompleteRef,
} = useTemplateAutocomplete({
textareaRef: editCommandTextareaRef,
value: editingCommand?.prompt || '',
onChange: (value) => editingCommand && setEditingCommand({ ...editingCommand, prompt: value }),
});
const handleSaveEdit = () => {
if (!editingCommand) return;
// Ensure command starts with /
const command = editingCommand.command.startsWith('/')
? editingCommand.command
: `/${editingCommand.command}`;
const updated = customAICommands.map(cmd =>
cmd.id === editingCommand.id
? { ...cmd, command, description: editingCommand.description, prompt: editingCommand.prompt }
: cmd
);
setCustomAICommands(updated);
setEditingCommand(null);
};
const handleCreate = () => {
if (!newCommand.command || !newCommand.description || !newCommand.prompt) return;
// Ensure command starts with /
const command = newCommand.command.startsWith('/')
? newCommand.command
: `/${newCommand.command}`;
// Generate ID from command name
const id = command.slice(1).toLowerCase().replace(/[^a-z0-9]/g, '-');
// Check for duplicate command
if (customAICommands.some(cmd => cmd.command === command)) {
return; // Could show error toast here
}
const newCmd: CustomAICommand = {
id: `custom-${id}-${Date.now()}`,
command,
description: newCommand.description,
prompt: newCommand.prompt,
isBuiltIn: false,
};
setCustomAICommands([...customAICommands, newCmd]);
setNewCommand({ id: '', command: '/', description: '', prompt: '' });
setIsCreating(false);
};
const handleDelete = (id: string) => {
const cmd = customAICommands.find(c => c.id === id);
if (cmd?.isBuiltIn) return; // Can't delete built-in commands
setCustomAICommands(customAICommands.filter(c => c.id !== id));
};
const handleCancelEdit = () => {
setEditingCommand(null);
};
const handleCancelCreate = () => {
setNewCommand({ id: '', command: '/', description: '', prompt: '' });
setIsCreating(false);
};
return (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-1 flex items-center gap-2">
<Terminal className="w-3 h-3" />
Custom AI Commands
</label>
<p className="text-xs opacity-50" style={{ color: theme.colors.textDim }}>
Slash commands available in AI terminal mode. Built-in commands can be edited but not deleted.
</p>
</div>
{/* Template Variables Documentation */}
<div
className="rounded-lg border overflow-hidden"
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}
>
<button
onClick={() => setVariablesExpanded(!variablesExpanded)}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Variable className="w-3.5 h-3.5" style={{ color: theme.colors.accent }} />
<span className="text-xs font-bold uppercase" style={{ color: theme.colors.textDim }}>
Template Variables
</span>
</div>
{variablesExpanded ? (
<ChevronDown className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
) : (
<ChevronRight className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
)}
</button>
{variablesExpanded && (
<div className="px-3 pb-3 pt-1 border-t" style={{ borderColor: theme.colors.border }}>
<p className="text-[10px] mb-2" style={{ color: theme.colors.textDim }}>
Use these variables in your command prompts. They will be replaced with actual values at runtime.
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 max-h-48 overflow-y-auto scrollbar-thin">
{TEMPLATE_VARIABLES_GENERAL.map(({ variable, description }) => (
<div key={variable} className="flex items-center gap-2 py-0.5">
<code
className="text-[10px] font-mono px-1 py-0.5 rounded shrink-0"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.accent }}
>
{variable}
</code>
<span className="text-[10px] truncate" style={{ color: theme.colors.textDim }}>
{description}
</span>
</div>
))}
</div>
</div>
)}
</div>
{!isCreating && (
<div className="flex justify-start">
<button
onClick={() => setIsCreating(true)}
className="flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-all"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.accentForeground
}}
>
<Plus className="w-4 h-4" />
Add Command
</button>
</div>
)}
{/* Create new command form */}
{isCreating && (
<div
className="p-4 rounded-lg border space-y-3"
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.accent }}
>
<div className="text-xs font-bold uppercase" style={{ color: theme.colors.accent }}>
New Command
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium opacity-70 mb-1">Command</label>
<input
type="text"
value={newCommand.command}
onChange={(e) => setNewCommand({ ...newCommand, command: e.target.value })}
placeholder="/mycommand"
className="w-full p-2 rounded border bg-transparent outline-none text-sm font-mono"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
</div>
<div>
<label className="block text-xs font-medium opacity-70 mb-1">Description</label>
<input
type="text"
value={newCommand.description}
onChange={(e) => setNewCommand({ ...newCommand, description: e.target.value })}
placeholder="Short description for autocomplete"
className="w-full p-2 rounded border bg-transparent outline-none text-sm"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
</div>
</div>
<div className="relative">
<label className="block text-xs font-medium opacity-70 mb-1">Prompt</label>
<textarea
ref={newCommandTextareaRef}
value={newCommand.prompt}
onChange={handleNewAutocompleteChange}
onKeyDown={(e) => {
if (handleNewAutocompleteKeyDown(e)) {
return;
}
// Allow Tab for indentation when autocomplete is not active
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
const newValue = value.substring(0, start) + '\t' + value.substring(end);
setNewCommand({ ...newCommand, prompt: newValue });
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
}, 0);
}
}}
placeholder="The actual prompt sent to the AI agent when this command is invoked... (type {{ for variables)"
rows={10}
className="w-full p-2 rounded border bg-transparent outline-none text-sm resize-y scrollbar-thin min-h-[150px]"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
<TemplateAutocompleteDropdown
ref={newAutocompleteRef}
theme={theme}
state={newAutocompleteState}
onSelect={selectNewVariable}
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelCreate}
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-all"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`
}}
>
<X className="w-3 h-3" />
Cancel
</button>
<button
onClick={handleCreate}
disabled={!newCommand.command || !newCommand.description || !newCommand.prompt}
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-all disabled:opacity-50"
style={{
backgroundColor: theme.colors.success,
color: '#000000'
}}
>
<Save className="w-3 h-3" />
Create
</button>
</div>
</div>
)}
{/* Existing commands list */}
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin">
{[...customAICommands].sort((a, b) => a.command.localeCompare(b.command)).map((cmd) => (
<div
key={cmd.id}
className="p-3 rounded-lg border"
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}
>
{editingCommand?.id === cmd.id ? (
// Editing mode - expanded to maximize space
<div className="space-y-3 flex flex-col">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium opacity-70 mb-1">Command</label>
<input
type="text"
value={editingCommand.command}
onChange={(e) => setEditingCommand({ ...editingCommand, command: e.target.value })}
className="w-full p-2 rounded border bg-transparent outline-none text-sm font-mono"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
</div>
<div>
<label className="block text-xs font-medium opacity-70 mb-1">Description</label>
<input
type="text"
value={editingCommand.description}
onChange={(e) => setEditingCommand({ ...editingCommand, description: e.target.value })}
className="w-full p-2 rounded border bg-transparent outline-none text-sm"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 relative">
<label className="block text-xs font-medium opacity-70 mb-1">Prompt</label>
<textarea
ref={editCommandTextareaRef}
value={editingCommand.prompt}
onChange={handleEditAutocompleteChange}
onKeyDown={(e) => {
if (handleEditAutocompleteKeyDown(e)) {
return;
}
// Allow Tab for indentation when autocomplete is not active
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
const newValue = value.substring(0, start) + '\t' + value.substring(end);
setEditingCommand({ ...editingCommand, prompt: newValue });
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
}, 0);
}
}}
rows={12}
className="w-full flex-1 p-2 rounded border bg-transparent outline-none text-sm resize-y scrollbar-thin min-h-[200px]"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
<TemplateAutocompleteDropdown
ref={editAutocompleteRef}
theme={theme}
state={editAutocompleteState}
onSelect={selectEditVariable}
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelEdit}
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-all"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`
}}
>
<X className="w-3 h-3" />
Cancel
</button>
<button
onClick={handleSaveEdit}
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-all"
style={{
backgroundColor: theme.colors.success,
color: '#000000'
}}
>
<Save className="w-3 h-3" />
Save
</button>
</div>
</div>
) : (
// Display mode
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-sm" style={{ color: theme.colors.accent }}>
{cmd.command}
</span>
{cmd.isBuiltIn && (
<span
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
title="Built-in command - can be edited but not deleted"
>
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setEditingCommand({
id: cmd.id,
command: cmd.command,
description: cmd.description,
prompt: cmd.prompt,
})}
className="p-1.5 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
title="Edit command"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{!cmd.isBuiltIn && (
<button
onClick={() => handleDelete(cmd.id)}
className="p-1.5 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.error }}
title="Delete command"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="text-xs mb-2" style={{ color: theme.colors.textDim }}>
{cmd.description}
</div>
<div
className="text-xs p-2 rounded font-mono overflow-y-auto max-h-24 scrollbar-thin whitespace-pre-wrap"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textMain }}
>
{cmd.prompt}
</div>
</div>
)}
</div>
))}
</div>
{customAICommands.length === 0 && !isCreating && (
<div
className="p-6 rounded-lg border border-dashed text-center"
style={{ borderColor: theme.colors.border }}
>
<Terminal className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p className="text-sm opacity-50" style={{ color: theme.colors.textDim }}>
No custom AI commands configured
</p>
<button
onClick={() => setIsCreating(true)}
className="mt-2 text-xs font-medium"
style={{ color: theme.colors.accent }}
>
Create your first command
</button>
</div>
)}
</div>
);
}