mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: new text input with rich content
This commit is contained in:
Generated
+15
@@ -55,6 +55,7 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typo-js": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.76"
|
||||
@@ -87,6 +88,7 @@
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/typo-js": "^1.2.2",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.1",
|
||||
"@typescript-eslint/parser": "^8.39.1",
|
||||
@@ -6395,6 +6397,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/typo-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/typo-js/-/typo-js-1.2.2.tgz",
|
||||
"integrity": "sha512-AgN5IwO3EPXv2d+UE9VdoOcueCBa4ZcHHwUvx2/IXlfRvkW4Yt3g+eRHzJbkySfj9pyILRT6Zk7V6Vr4LD5Kvg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -19476,6 +19485,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typo-js": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz",
|
||||
"integrity": "sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typo-js": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.76"
|
||||
@@ -117,6 +118,7 @@
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/typo-js": "^1.2.2",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.1",
|
||||
"@typescript-eslint/parser": "^8.39.1",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const pillStyles = `
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
from, to {
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.pill-expand-in {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border-right: 2px solid currentColor;
|
||||
animation:
|
||||
typewriter 0.4s steps(20, end) forwards,
|
||||
blink-caret 0.5s step-end 3;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== "undefined" && !document.getElementById("pill-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "pill-styles";
|
||||
style.textContent = pillStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ActionPillProps {
|
||||
actionId: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onRemove?: () => void; // Optional for read-only pills in messages
|
||||
variant?: 'default' | 'message'; // Different styles for input vs message display
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export const ActionPill: React.FC<ActionPillProps> = ({
|
||||
label,
|
||||
icon,
|
||||
onRemove,
|
||||
variant = 'default',
|
||||
size = 'sm'
|
||||
}) => {
|
||||
const baseClasses = "inline-flex items-center gap-1.5 font-medium border rounded-full";
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800 dark:hover:bg-blue-900",
|
||||
message: "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700"
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-1.5 text-sm"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} transition-colors animate-in fade-in-0 slide-in-from-left-1 duration-200`}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-500 flex items-center justify-center w-3 h-3">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors animate-in fade-in-0 slide-in-from-left-1 duration-200"
|
||||
aria-label={`Remove ${label} action`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionPill;
|
||||
@@ -0,0 +1,359 @@
|
||||
const popoverStyles = `
|
||||
@keyframes popoverFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) scaleY(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%) scaleY(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== "undefined" && !document.getElementById("popover-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "popover-styles";
|
||||
style.textContent = popoverStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Zap, FileText, Code, Settings, Search, Play, Hash, Plus, SearchX } from 'lucide-react';
|
||||
import { CustomCommand, BUILT_IN_COMMANDS } from '../types/customCommands';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface ActionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
isCustom?: boolean;
|
||||
prompt?: string; // For custom commands
|
||||
}
|
||||
|
||||
interface ActionPopoverProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (actionId: string) => void;
|
||||
position: { x: number; y: number };
|
||||
selectedIndex: number;
|
||||
onSelectedIndexChange: (index: number) => void;
|
||||
query?: string; // Filter actions based on query
|
||||
onCreateCommand?: () => void; // Callback to open command creation modal
|
||||
}
|
||||
|
||||
const ActionPopover = forwardRef<
|
||||
{ getDisplayActions: () => ActionItem[]; selectAction: (index: number) => void },
|
||||
ActionPopoverProps
|
||||
>(({ isOpen, onClose, onSelect, position, selectedIndex, onSelectedIndexChange, query = '', onCreateCommand }, ref) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [allCommands, setAllCommands] = useState<CustomCommand[]>([]);
|
||||
|
||||
// Load both built-in and user commands on mount
|
||||
useEffect(() => {
|
||||
const loadAllCommands = () => {
|
||||
try {
|
||||
// Load user commands
|
||||
const userStored = localStorage.getItem('goose-custom-commands');
|
||||
let userCommands: CustomCommand[] = [];
|
||||
if (userStored) {
|
||||
const parsed = JSON.parse(userStored);
|
||||
userCommands = parsed
|
||||
.filter((cmd: any) => !cmd.isBuiltIn) // Only user commands
|
||||
.map((cmd: any) => ({
|
||||
...cmd,
|
||||
createdAt: new Date(cmd.createdAt),
|
||||
updatedAt: new Date(cmd.updatedAt)
|
||||
}));
|
||||
}
|
||||
|
||||
// Load built-in command favorites/usage
|
||||
const builtInStored = localStorage.getItem('goose-builtin-commands');
|
||||
let builtInCommands = [...BUILT_IN_COMMANDS];
|
||||
if (builtInStored) {
|
||||
const builtInData = JSON.parse(builtInStored);
|
||||
builtInCommands = BUILT_IN_COMMANDS.map(cmd => ({
|
||||
...cmd,
|
||||
isFavorite: builtInData[cmd.id]?.isFavorite || false,
|
||||
usageCount: builtInData[cmd.id]?.usageCount || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// Combine all commands
|
||||
setAllCommands([...builtInCommands, ...userCommands]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load commands:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
loadAllCommands();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Icon mapping for custom commands
|
||||
const getCustomCommandIcon = (iconName?: string) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'Zap': <Zap size={16} />,
|
||||
'Code': <Code size={16} />,
|
||||
'FileText': <FileText size={16} />,
|
||||
'Search': <Search size={16} />,
|
||||
'Play': <Play size={16} />,
|
||||
'Settings': <Settings size={16} />,
|
||||
'Hash': <Hash size={16} />,
|
||||
};
|
||||
return iconMap[iconName || 'Zap'] || <Zap size={16} />;
|
||||
};
|
||||
|
||||
// Convert all commands to action items
|
||||
const allActions: ActionItem[] = allCommands.map(cmd => ({
|
||||
id: cmd.id,
|
||||
label: cmd.label,
|
||||
description: cmd.description,
|
||||
icon: getCustomCommandIcon(cmd.icon),
|
||||
isCustom: !cmd.isBuiltIn, // Built-in commands are not "custom"
|
||||
prompt: cmd.prompt,
|
||||
action: () => {
|
||||
console.log('Command action triggered:', cmd.name);
|
||||
// Increment usage count for both built-in and user commands
|
||||
if (cmd.isBuiltIn) {
|
||||
// Update built-in command usage
|
||||
const builtInStored = localStorage.getItem('goose-builtin-commands');
|
||||
let builtInData: Record<string, { isFavorite: boolean; usageCount: number }> = {};
|
||||
if (builtInStored) {
|
||||
builtInData = JSON.parse(builtInStored);
|
||||
}
|
||||
builtInData[cmd.id] = {
|
||||
isFavorite: builtInData[cmd.id]?.isFavorite || cmd.isFavorite,
|
||||
usageCount: (builtInData[cmd.id]?.usageCount || cmd.usageCount) + 1,
|
||||
};
|
||||
localStorage.setItem('goose-builtin-commands', JSON.stringify(builtInData));
|
||||
} else {
|
||||
// Update user command usage
|
||||
const userStored = localStorage.getItem('goose-custom-commands');
|
||||
if (userStored) {
|
||||
const userCommands = JSON.parse(userStored);
|
||||
const updatedCommands = userCommands.map((c: any) =>
|
||||
c.id === cmd.id ? { ...c, usageCount: c.usageCount + 1 } : c
|
||||
);
|
||||
localStorage.setItem('goose-custom-commands', JSON.stringify(updatedCommands));
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Filter commands based on query
|
||||
const filteredActions = allActions.filter(action => {
|
||||
const cmd = allCommands.find(c => c.id === action.id);
|
||||
|
||||
// If no query, show only starred commands
|
||||
if (!query) {
|
||||
return cmd?.isFavorite === true;
|
||||
}
|
||||
|
||||
// If there's a query, search through all commands
|
||||
const searchTerm = query.toLowerCase();
|
||||
return (
|
||||
action.label.toLowerCase().includes(searchTerm) ||
|
||||
action.description.toLowerCase().includes(searchTerm) ||
|
||||
action.id.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
// Sort actions: favorites first, then by usage count, then alphabetically
|
||||
const sortedActions = filteredActions.sort((a, b) => {
|
||||
const cmdA = allCommands.find(c => c.id === a.id);
|
||||
const cmdB = allCommands.find(c => c.id === b.id);
|
||||
|
||||
if (cmdA?.isFavorite && !cmdB?.isFavorite) return -1;
|
||||
if (!cmdA?.isFavorite && cmdB?.isFavorite) return 1;
|
||||
|
||||
if (cmdA && cmdB) {
|
||||
if (cmdA.usageCount !== cmdB.usageCount) {
|
||||
return cmdB.usageCount - cmdA.usageCount;
|
||||
}
|
||||
}
|
||||
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
// Expose methods to parent component
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getDisplayActions: () => sortedActions,
|
||||
selectAction: (index: number) => {
|
||||
console.log('⌨️ ActionPopover: selectAction called via keyboard', { index, actionId: sortedActions[index]?.id });
|
||||
if (sortedActions[index]) {
|
||||
console.log('🔄 ActionPopover: Calling onSelect from selectAction:', sortedActions[index].id);
|
||||
onSelect(sortedActions[index].id);
|
||||
sortedActions[index].action();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 10);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[sortedActions, onSelect, onClose]
|
||||
);
|
||||
|
||||
// Handle clicks outside the popover
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleItemClick = (index: number) => {
|
||||
console.log('🎯 ActionPopover: handleItemClick called', { index, actionId: sortedActions[index].id });
|
||||
console.log('📋 ActionPopover: onSelect function:', onSelect);
|
||||
console.log('🔄 ActionPopover: About to call onSelect with:', sortedActions[index].id);
|
||||
|
||||
onSelectedIndexChange(index);
|
||||
|
||||
// Call onSelect first - this should trigger handleActionSelect in ChatInput
|
||||
onSelect(sortedActions[index].id);
|
||||
|
||||
console.log('✅ ActionPopover: onSelect called successfully');
|
||||
|
||||
// Call the local action (just for logging)
|
||||
sortedActions[index].action();
|
||||
|
||||
// Close popover after a small delay to allow text replacement to complete
|
||||
console.log('🚪 ActionPopover: Closing popover after delay');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 10);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-background-default border border-borderStandard rounded-2xl min-w-80 max-w-md "
|
||||
style={{ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05)", transformOrigin: "bottom", animation: "popoverFadeIn 0.2s ease-out forwards", opacity: 0, transform: "translateY(-100%) scaleY(0.8)",
|
||||
left: position.x,
|
||||
top: position.y - 10,
|
||||
|
||||
}}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium text-textStandard">
|
||||
{query ? 'Search Results' : 'Starred Commands'}
|
||||
</h3>
|
||||
<p className="text-xs text-textSubtle">
|
||||
{query ? `Commands matching "${query}"` : 'Your favorite slash commands'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="space-y-1">
|
||||
{sortedActions.length > 0 ? (
|
||||
sortedActions.map((action, index) => (
|
||||
<div
|
||||
key={action.id}
|
||||
onClick={() => handleItemClick(index)}
|
||||
className={`flex items-center gap-3 p-2 rounded-2xl cursor-pointer transition-all ${
|
||||
index === selectedIndex
|
||||
? 'bg-gray-100 dark:bg-gray-700'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 text-textSubtle">
|
||||
{action.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mx-auto">
|
||||
<div className="text-sm font-medium text-textStandard">
|
||||
{action.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-textSubtle">
|
||||
{action.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-center text-textSubtle">
|
||||
<div className="text-sm mb-2 text-textMuted">
|
||||
{query
|
||||
? <><SearchX size={24} className="text-textMuted mx-auto mb-1" /></>
|
||||
: allCommands.length === 0
|
||||
? 'No commands found'
|
||||
: 'No starred commands found'
|
||||
}
|
||||
</div>
|
||||
{query && onCreateCommand ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreateCommand();
|
||||
onClose();
|
||||
}}
|
||||
variant="ghost" size="sm"
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create Command
|
||||
</Button>
|
||||
) : !query && allCommands.length === 0 ? (
|
||||
onCreateCommand ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreateCommand();
|
||||
onClose();
|
||||
}}
|
||||
variant="ghost" size="sm"
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create Command
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs">Create commands in Settings → Chat</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-xs">Star commands to see them here when you type /</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ActionPopover.displayName = 'ActionPopover';
|
||||
|
||||
export default ActionPopover;
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Zap, Code, FileText, Search, Play, Settings, Hash } from 'lucide-react';
|
||||
import { CustomCommand } from '../types/customCommands';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface AddCustomCommandModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (command: CustomCommand) => void;
|
||||
editingCommand?: CustomCommand | null;
|
||||
}
|
||||
|
||||
const iconOptions = [
|
||||
{ name: 'Zap', icon: <Zap size={16} /> },
|
||||
{ name: 'Code', icon: <Code size={16} /> },
|
||||
{ name: 'FileText', icon: <FileText size={16} /> },
|
||||
{ name: 'Search', icon: <Search size={16} /> },
|
||||
{ name: 'Play', icon: <Play size={16} /> },
|
||||
{ name: 'Settings', icon: <Settings size={16} /> },
|
||||
{ name: 'Hash', icon: <Hash size={16} /> },
|
||||
];
|
||||
|
||||
export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editingCommand,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
label: '',
|
||||
description: '',
|
||||
prompt: '',
|
||||
icon: 'Zap',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Reset form when modal opens/closes or editing command changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingCommand) {
|
||||
setFormData({
|
||||
name: editingCommand.name,
|
||||
label: editingCommand.label,
|
||||
description: editingCommand.description,
|
||||
prompt: editingCommand.prompt,
|
||||
icon: editingCommand.icon || 'Zap',
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
label: '',
|
||||
description: '',
|
||||
prompt: '',
|
||||
icon: 'Zap',
|
||||
});
|
||||
}
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen, editingCommand]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Command name is required';
|
||||
} else if (!/^[a-zA-Z0-9-_]+$/.test(formData.name)) {
|
||||
newErrors.name = 'Command name can only contain letters, numbers, hyphens, and underscores';
|
||||
}
|
||||
|
||||
if (!formData.label.trim()) {
|
||||
newErrors.label = 'Display label is required';
|
||||
}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
}
|
||||
|
||||
if (!formData.prompt.trim()) {
|
||||
newErrors.prompt = 'Prompt is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command: CustomCommand = {
|
||||
id: editingCommand?.id || `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: formData.name.trim(),
|
||||
label: formData.label.trim(),
|
||||
description: formData.description.trim(),
|
||||
prompt: formData.prompt.trim(),
|
||||
icon: formData.icon,
|
||||
category: editingCommand?.category || undefined,
|
||||
createdAt: editingCommand?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: editingCommand?.usageCount || 0,
|
||||
isFavorite: editingCommand?.isFavorite || false,
|
||||
};
|
||||
|
||||
onSave(command);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
// Auto-generate label from name if label is empty
|
||||
if (field === 'name' && !formData.label) {
|
||||
const autoLabel = value
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase())
|
||||
.trim();
|
||||
setFormData(prev => ({ ...prev, label: autoLabel }));
|
||||
}
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCommand ? 'Edit Custom Command' : 'Add Custom Command'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Command Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Command Name *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-textSubtle">
|
||||
/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="document"
|
||||
className={`w-full pl-8 pr-3 py-2 border rounded-md bg-background-default text-textStandard placeholder-textSubtle ${
|
||||
errors.name ? 'border-red-500' : 'border-borderStandard'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
This will be the command users type (e.g., /document)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Display Label */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Display Label *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.label}
|
||||
onChange={(e) => handleInputChange('label', e.target.value)}
|
||||
placeholder="Create Document"
|
||||
className={`w-full px-3 py-2 border rounded-md bg-background-default text-textStandard placeholder-textSubtle ${
|
||||
errors.label ? 'border-red-500' : 'border-borderStandard'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
|
||||
/>
|
||||
{errors.label && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.label}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Friendly name shown in the command list
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Create a new document with proper formatting"
|
||||
className={`w-full px-3 py-2 border rounded-md bg-background-default text-textStandard placeholder-textSubtle ${
|
||||
errors.description ? 'border-red-500' : 'border-borderStandard'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Icon
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{iconOptions.map((option) => (
|
||||
<button
|
||||
key={option.name}
|
||||
type="button"
|
||||
onClick={() => handleInputChange('icon', option.name)}
|
||||
className={`p-2 rounded-md border transition-colors ${
|
||||
formData.icon === option.name
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-borderStandard hover:bg-bgSubtle'
|
||||
}`}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Prompt *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.prompt}
|
||||
onChange={(e) => handleInputChange('prompt', e.target.value)}
|
||||
placeholder="Please create a comprehensive document about the topic provided. Include an introduction, main sections with detailed explanations, and a conclusion. Use proper markdown formatting with headers, bullet points, and code blocks where appropriate."
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 border rounded-md bg-background-default text-textStandard placeholder-textSubtle resize-vertical ${
|
||||
errors.prompt ? 'border-red-500' : 'border-borderStandard'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent`}
|
||||
/>
|
||||
{errors.prompt && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.prompt}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
This is the full prompt that will be sent to the AI when the command is used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
{editingCommand ? 'Update Command' : 'Add Command'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCustomCommandModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
const pillStyles = `
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
from, to {
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.pill-expand-in {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border-right: 2px solid currentColor;
|
||||
animation:
|
||||
typewriter 0.4s steps(20, end) forwards,
|
||||
blink-caret 0.5s step-end 3;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== "undefined" && !document.getElementById("pill-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "pill-styles";
|
||||
style.textContent = pillStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
import React from 'react';
|
||||
import { X, Diamond } from 'lucide-react';
|
||||
|
||||
interface MentionPillProps {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
onRemove?: () => void; // Optional for read-only pills in messages
|
||||
variant?: 'default' | 'message'; // Different styles for input vs message display
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export const MentionPill: React.FC<MentionPillProps> = ({
|
||||
fileName,
|
||||
filePath,
|
||||
onRemove,
|
||||
variant = 'default',
|
||||
size = 'sm'
|
||||
}) => {
|
||||
const baseClasses = "inline-flex items-center gap-1.5 font-medium border rounded-full";
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-800 dark:hover:bg-orange-900",
|
||||
message: "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-900 dark:text-orange-200 dark:border-orange-700"
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-1.5 text-sm"
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} transition-colors animate-in fade-in-0 slide-in-from-left-1 duration-200`}
|
||||
title={filePath} // Show full path on hover
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<Diamond size={12} className="text-orange-500 fill-orange-500" />
|
||||
{fileName}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-orange-200 dark:hover:bg-orange-800 transition-colors animate-in fade-in-0 slide-in-from-left-1 duration-200"
|
||||
aria-label={`Remove ${fileName} mention`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionPill;
|
||||
@@ -1,3 +1,24 @@
|
||||
const popoverStyles = `
|
||||
@keyframes popoverFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) scaleY(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%) scaleY(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== "undefined" && !document.getElementById("popover-styles-mention")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "popover-styles-mention";
|
||||
style.textContent = popoverStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -468,11 +489,11 @@ const MentionPopover = forwardRef<
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg max-h-80"
|
||||
style={{
|
||||
className="fixed z-50 bg-background-default border border-borderStandard rounded-2xl min-w-96 max-w-lg max-h-80 "
|
||||
style={{ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05)", transformOrigin: "bottom", animation: "popoverFadeIn 0.2s ease-out forwards", opacity: 0, transform: "translateY(-100%) scaleY(0.8)",
|
||||
left: position.x,
|
||||
top: position.y - 10, // Position above the chat input
|
||||
transform: 'translateY(-100%)', // Move it fully above
|
||||
|
||||
}}
|
||||
>
|
||||
<div className="p-3 flex flex-col max-h-80">
|
||||
@@ -497,18 +518,22 @@ const MentionPopover = forwardRef<
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => handleItemClick(index)}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
|
||||
className={`flex items-center gap-3 p-2 rounded-2xl cursor-pointer transition-all ${
|
||||
index === selectedIndex
|
||||
? 'bg-bgProminent text-textProminentInverse'
|
||||
: 'hover:bg-bgSubtle'
|
||||
? 'bg-gray-100 dark:bg-gray-700'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 text-textSubtle">
|
||||
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate text-textStandard">{file.name}</div>
|
||||
<div className="text-xs text-textSubtle truncate">{file.path}</div>
|
||||
<div className="text-sm truncate text-textStandard">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-xs truncate text-textSubtle">
|
||||
{file.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ActionPill from './ActionPill';
|
||||
import MentionPill from './MentionPill';
|
||||
import { Zap, Code, FileText, Search, Play, Settings } from 'lucide-react';
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Icon mapping for custom commands
|
||||
const getCustomCommandIcon = (iconName?: string) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'Zap': <Zap size={12} />,
|
||||
'Code': <Code size={12} />,
|
||||
'FileText': <FileText size={12} />,
|
||||
'Search': <Search size={12} />,
|
||||
'Play': <Play size={12} />,
|
||||
'Settings': <Settings size={12} />,
|
||||
};
|
||||
return iconMap[iconName || 'Zap'] || <Zap size={12} />;
|
||||
};
|
||||
|
||||
// Dynamic action mapping that loads from localStorage
|
||||
const getActionMap = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('goose-custom-commands');
|
||||
if (stored) {
|
||||
const commands = JSON.parse(stored);
|
||||
const actionMap: Record<string, { label: string; icon: React.ReactNode }> = {};
|
||||
|
||||
commands.forEach((cmd: any) => {
|
||||
actionMap[cmd.id] = {
|
||||
label: cmd.label,
|
||||
icon: getCustomCommandIcon(cmd.icon),
|
||||
};
|
||||
});
|
||||
|
||||
return actionMap;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading custom commands for action map:', error);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
// Helper function to get action info
|
||||
const getActionInfo = (actionId: string) => {
|
||||
const actionMap = getActionMap();
|
||||
return actionMap[actionId] || { label: actionId, icon: <Zap size={12} /> };
|
||||
};
|
||||
|
||||
// Map action labels back to action IDs for rendering
|
||||
const getActionIdFromLabel = (label: string): string => {
|
||||
const actionMap = getActionMap();
|
||||
const entry = Object.entries(actionMap).find(([_, config]) => config.label === label);
|
||||
return entry ? entry[0] : label.toLowerCase().replace(/\s+/g, '-');
|
||||
};
|
||||
|
||||
export const MessageContent: React.FC<MessageContentProps> = ({ content, className }) => {
|
||||
const parsedContent = useMemo(() => {
|
||||
// Find all [Action] and @mention patterns and replace them with pill components
|
||||
const actionRegex = /\[([^\]]+)\]/g;
|
||||
const mentionRegex = /@([^\s]+)/g;
|
||||
const parts: Array<{ type: 'text' | 'action' | 'mention'; content: string; actionId?: string; fileName?: string }> = [];
|
||||
|
||||
// Find all matches and sort by position
|
||||
const allMatches = [];
|
||||
|
||||
// Find all action matches
|
||||
let actionMatch;
|
||||
actionRegex.lastIndex = 0;
|
||||
while ((actionMatch = actionRegex.exec(content)) !== null) {
|
||||
allMatches.push({
|
||||
type: 'action',
|
||||
index: actionMatch.index,
|
||||
length: actionMatch[0].length,
|
||||
content: actionMatch[1]
|
||||
});
|
||||
}
|
||||
|
||||
// Find all mention matches
|
||||
let mentionMatch;
|
||||
mentionRegex.lastIndex = 0;
|
||||
while ((mentionMatch = mentionRegex.exec(content)) !== null) {
|
||||
allMatches.push({
|
||||
type: 'mention',
|
||||
index: mentionMatch.index,
|
||||
length: mentionMatch[0].length,
|
||||
content: mentionMatch[1] // filename without @
|
||||
});
|
||||
}
|
||||
|
||||
allMatches.sort((a, b) => a.index - b.index);
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const match of allMatches) {
|
||||
// Add text before this match
|
||||
if (match.index > currentIndex) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: content.slice(currentIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
if (match.type === 'action') {
|
||||
// Add the action
|
||||
const actionLabel = match.content;
|
||||
const actionId = getActionIdFromLabel(actionLabel);
|
||||
|
||||
parts.push({
|
||||
type: 'action',
|
||||
content: actionLabel,
|
||||
actionId: actionId,
|
||||
});
|
||||
} else if (match.type === 'mention') {
|
||||
// Add the mention
|
||||
parts.push({
|
||||
type: 'mention',
|
||||
content: match.content, // filename without @
|
||||
fileName: match.content,
|
||||
});
|
||||
}
|
||||
|
||||
currentIndex = match.index + match.length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (currentIndex < content.length) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: content.slice(currentIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// If no matches found, return the original content as a single text part
|
||||
if (parts.length === 0) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: content,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<span className={`inline ${className || ''}`}>
|
||||
{parsedContent.map((part, index) => {
|
||||
if (part.type === 'action' && part.actionId) {
|
||||
const actionInfo = getActionInfo(part.actionId);
|
||||
return (
|
||||
<ActionPill
|
||||
key={`action-${index}`}
|
||||
actionId={part.actionId}
|
||||
label={part.content}
|
||||
icon={actionInfo.icon}
|
||||
variant="message"
|
||||
size="sm"
|
||||
// No onRemove for message display - pills are read-only
|
||||
/>
|
||||
);
|
||||
} else if (part.type === 'mention' && part.fileName) {
|
||||
return (
|
||||
<MentionPill
|
||||
key={`mention-${index}`}
|
||||
fileName={part.fileName}
|
||||
filePath={`@${part.fileName}`}
|
||||
variant="message"
|
||||
size="sm"
|
||||
// No onRemove for message display - pills are read-only
|
||||
/>
|
||||
);
|
||||
} else if (part.content.trim()) {
|
||||
return (
|
||||
<span
|
||||
key={`text-${index}`}
|
||||
className={`inline ${className || ''}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: part.content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageContent;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface SpellCheckTooltipProps {
|
||||
isVisible: boolean;
|
||||
position: { x: number; y: number };
|
||||
suggestions: string[];
|
||||
misspelledWord: string;
|
||||
onSuggestionSelect: (suggestion: string) => void;
|
||||
onAddToDictionary: () => void;
|
||||
onIgnore: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
const SpellCheckTooltip: React.FC<SpellCheckTooltipProps> = ({
|
||||
isVisible,
|
||||
position,
|
||||
suggestions,
|
||||
misspelledWord,
|
||||
onSuggestionSelect,
|
||||
onAddToDictionary,
|
||||
onIgnore,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Adjust position to keep tooltip within viewport
|
||||
useEffect(() => {
|
||||
if (isVisible && tooltipRef.current) {
|
||||
const tooltip = tooltipRef.current;
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const tooltipWidth = rect.width || 200; // fallback width
|
||||
const tooltipHeight = rect.height || 100; // fallback height
|
||||
|
||||
let adjustedX = position.x;
|
||||
let adjustedY = position.y;
|
||||
|
||||
// Calculate the tooltip's left edge when centered
|
||||
const tooltipLeftEdge = position.x - (tooltipWidth / 2);
|
||||
const tooltipRightEdge = position.x + (tooltipWidth / 2);
|
||||
|
||||
// Adjust horizontal position if tooltip would overflow
|
||||
if (tooltipLeftEdge < 10) {
|
||||
// If left edge would be cut off, align to left edge with padding
|
||||
adjustedX = (tooltipWidth / 2) + 10;
|
||||
} else if (tooltipRightEdge > viewportWidth - 10) {
|
||||
// If right edge would be cut off, align to right edge with padding
|
||||
adjustedX = viewportWidth - (tooltipWidth / 2) - 10;
|
||||
}
|
||||
|
||||
// Adjust vertical position if tooltip would overflow
|
||||
if (position.y + tooltipHeight + 50 > viewportHeight) {
|
||||
// Show above the word instead of below
|
||||
adjustedY = position.y - tooltipHeight - 10;
|
||||
}
|
||||
|
||||
// Ensure tooltip doesn't go above viewport
|
||||
if (adjustedY < 10) {
|
||||
adjustedY = 10;
|
||||
}
|
||||
|
||||
setAdjustedPosition({ x: adjustedX, y: adjustedY });
|
||||
}
|
||||
}, [isVisible, position]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
console.log('🖱️ TOOLTIP COMPONENT: Rendering visible tooltip');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
tabIndex={-1} // Make it focusable for keyboard events
|
||||
data-spell-tooltip="true" // For click detection
|
||||
className="fixed z-50 bg-background-default border border-border-default rounded-lg shadow-xl py-2 min-w-48 max-w-64 outline-none"
|
||||
style={{
|
||||
left: `${adjustedPosition.x}px`,
|
||||
top: `${adjustedPosition.y - 5}px`, // Position right above the word
|
||||
transform: 'translateX(-50%)', // Only center horizontally
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.15), 0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-1 text-xs text-text-muted border-b border-border-subtle mb-1 font-medium">
|
||||
Suggestions for "<span className="text-red-600 dark:text-red-400 font-semibold">{misspelledWord}</span>"
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 ? (
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
{suggestions.slice(0, 5).map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('🖱️ SUGGESTION CLICKED:', suggestion);
|
||||
onSuggestionSelect(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-all duration-150 flex items-center gap-2 ${
|
||||
selectedIndex === index
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-100 border-l-2 border-blue-500'
|
||||
: 'text-text-default hover:bg-background-subtle'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-5 h-5 flex items-center justify-center text-xs rounded text-[10px] font-bold ${
|
||||
selectedIndex === index
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-text-muted text-white'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-medium truncate">{suggestion}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-text-muted italic">
|
||||
No suggestions available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border-subtle my-1" />
|
||||
|
||||
{/* Additional actions */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('🖱️ ADD TO DICTIONARY CLICKED');
|
||||
onAddToDictionary();
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-background-subtle transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="text-green-600 dark:text-green-400">+</span>
|
||||
Add to dictionary
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('🖱️ IGNORE CLICKED');
|
||||
onIgnore();
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-background-subtle transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="text-text-muted">×</span>
|
||||
Ignore word
|
||||
</button>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="px-3 py-1 text-[10px] text-text-muted border-t border-border-subtle mt-1">
|
||||
Press 1-5 to select • ↑↓ to navigate • Enter to apply • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpellCheckTooltip;
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import MessageContent from './MessageContent';
|
||||
import { getTextContent } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
@@ -37,6 +38,11 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
// Memoize the timestamp
|
||||
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
|
||||
|
||||
// Check if the message contains action pills
|
||||
const hasActionPills = useMemo(() => {
|
||||
return /\[[^\]]+\]/.test(displayText);
|
||||
}, [displayText]);
|
||||
|
||||
// Effect to handle message content changes and ensure persistence
|
||||
useEffect(() => {
|
||||
// If we're not editing, update the edit content to match the current message
|
||||
@@ -193,10 +199,17 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
<div className="flex flex-col group">
|
||||
<div className="flex bg-background-accent text-text-on-accent rounded-xl py-2.5 px-4">
|
||||
<div ref={contentRef}>
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
className="text-text-on-accent prose-a:text-text-on-accent prose-headings:text-text-on-accent prose-strong:text-text-on-accent prose-em:text-text-on-accent user-message"
|
||||
/>
|
||||
{hasActionPills ? (
|
||||
<MessageContent
|
||||
content={displayText}
|
||||
className="text-text-on-accent prose-a:text-text-on-accent prose-headings:text-text-on-accent prose-strong:text-text-on-accent prose-em:text-text-on-accent user-message"
|
||||
/>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
className="text-text-on-accent prose-a:text-text-on-accent prose-headings:text-text-on-accent prose-strong:text-text-on-accent prose-em:text-text-on-accent user-message"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ export const BottomMenuModeSelection = () => {
|
||||
<div title={`Current mode: ${getValueByKey(gooseMode)} - ${getModeDescription(gooseMode)}`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span className="flex items-center cursor-pointer [&_svg]:size-4 text-text-default/70 hover:text-text-default hover:scale-100 hover:bg-transparent text-xs">
|
||||
<Tornado className="mr-1 h-4 w-4" />
|
||||
{getValueByKey(gooseMode).toLowerCase()}
|
||||
</span>
|
||||
<button className="flex items-center cursor-pointer text-text-default/70 hover:text-text-default transition-colors text-xs px-1">
|
||||
<Tornado className="min-[1050px]:mr-1 h-4 w-4" />
|
||||
<span className="text-xs hidden min-[1050px]:inline">{getValueByKey(gooseMode)}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" side="top" align="center">
|
||||
{all_goose_modes.map((mode) => (
|
||||
|
||||
@@ -185,8 +185,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
|
||||
<span className="text-xs font-mono">...</span>
|
||||
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px] px-1">
|
||||
<span className="text-xs font-mono hidden min-[1050px]:inline">...</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border-default mx-2" />
|
||||
</>
|
||||
@@ -205,9 +205,9 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-full text-text-default/70 hover:text-text-default transition-colors cursor-default translate-y-[1px]">
|
||||
<div className="flex items-center justify-center h-full text-text-default/70 hover:text-text-default transition-colors cursor-default translate-y-[1px] px-1">
|
||||
<CoinIcon className="mr-1" size={16} />
|
||||
<span className="text-xs font-mono">0.0000</span>
|
||||
<span className="text-xs font-mono hidden min-[1050px]:inline">0.0000</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -234,7 +234,7 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
||||
<CoinIcon className="mr-1" size={16} />
|
||||
<span className="text-xs font-mono">0.0000</span>
|
||||
<span className="text-xs font-mono hidden min-[1050px]:inline">0.0000</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getUnavailableTooltip()}</TooltipContent>
|
||||
@@ -291,7 +291,7 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
||||
<CoinIcon className="mr-1" size={16} />
|
||||
<span className="text-xs font-mono">{formatCost(totalCost)}</span>
|
||||
<span className="text-xs font-mono hidden min-[1050px]:inline">{formatCost(totalCost)}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTooltipContent()}</TooltipContent>
|
||||
|
||||
@@ -31,11 +31,11 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({ className = '' }) => {
|
||||
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={`z-[100] hover:cursor-pointer text-text-default/70 hover:text-text-default text-xs flex items-center transition-colors pl-1 [&>svg]:size-4 ${className}`}
|
||||
className={`z-[100] hover:cursor-pointer text-text-default/70 hover:text-text-default text-xs flex items-center transition-colors px-1 [&>svg]:size-4 ${className}`}
|
||||
onClick={handleDirectoryClick}
|
||||
>
|
||||
<FolderDot className="mr-1" size={16} />
|
||||
<div className="max-w-[200px] truncate [direction:rtl]">
|
||||
<FolderDot className="min-[1050px]:mr-1" size={16} />
|
||||
<div className="max-w-[200px] truncate [direction:rtl] hidden min-[1050px]:block">
|
||||
{String(window.appConfig.get('GOOSE_WORKING_DIR'))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ActionProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const Action: React.FC<ActionProps> = ({ className = '', size = 16 }) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
{/* Plus symbol */}
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Action;
|
||||
@@ -1,3 +1,4 @@
|
||||
import Action from './Action';
|
||||
import ArrowDown from './ArrowDown';
|
||||
import ArrowUp from './ArrowUp';
|
||||
import Attach from './Attach';
|
||||
@@ -49,6 +50,7 @@ import { Watch5 } from './Watch5';
|
||||
import { Watch6 } from './Watch6';
|
||||
|
||||
export {
|
||||
Action,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Attach,
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, Copy, Download, Upload, Star, StarOff, Zap, Code, FileText, Search } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { AddCustomCommandModal } from '../AddCustomCommandModal';
|
||||
import {
|
||||
CustomCommand,
|
||||
CustomCommandCategory,
|
||||
DEFAULT_CATEGORIES,
|
||||
BUILT_IN_COMMANDS
|
||||
} from '../../types/customCommands';
|
||||
|
||||
interface CustomCommandsSettingsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ICON_MAP = {
|
||||
'Zap': <Zap size={16} />,
|
||||
'Code': <Code size={16} />,
|
||||
'FileText': <FileText size={16} />,
|
||||
'Search': <Search size={16} />,
|
||||
};
|
||||
|
||||
export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = () => {
|
||||
const [userCommands, setUserCommands] = useState<CustomCommand[]>([]);
|
||||
const [builtInCommands, setBuiltInCommands] = useState<CustomCommand[]>(BUILT_IN_COMMANDS);
|
||||
const [categories] = useState<CustomCommandCategory[]>(DEFAULT_CATEGORIES);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCommand, setEditingCommand] = useState<CustomCommand | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Combine built-in and user commands
|
||||
const allCommands = [...builtInCommands, ...userCommands];
|
||||
|
||||
// Load commands from storage on mount
|
||||
useEffect(() => {
|
||||
loadCommands();
|
||||
}, []);
|
||||
|
||||
const loadCommands = async () => {
|
||||
try {
|
||||
// Load user commands from localStorage
|
||||
const stored = localStorage.getItem('goose-custom-commands');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const userCmds = parsed
|
||||
.filter((cmd: any) => !cmd.isBuiltIn) // Only user commands
|
||||
.map((cmd: any) => ({
|
||||
...cmd,
|
||||
createdAt: new Date(cmd.createdAt),
|
||||
updatedAt: new Date(cmd.updatedAt)
|
||||
}));
|
||||
setUserCommands(userCmds);
|
||||
}
|
||||
|
||||
// Load built-in command favorites/usage from localStorage
|
||||
const builtInStored = localStorage.getItem('goose-builtin-commands');
|
||||
if (builtInStored) {
|
||||
const builtInData = JSON.parse(builtInStored);
|
||||
const updatedBuiltIns = BUILT_IN_COMMANDS.map(cmd => ({
|
||||
...cmd,
|
||||
isFavorite: builtInData[cmd.id]?.isFavorite || false,
|
||||
usageCount: builtInData[cmd.id]?.usageCount || 0,
|
||||
}));
|
||||
setBuiltInCommands(updatedBuiltIns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom commands:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserCommands = async (updatedUserCommands: CustomCommand[]) => {
|
||||
try {
|
||||
localStorage.setItem('goose-custom-commands', JSON.stringify(updatedUserCommands));
|
||||
setUserCommands(updatedUserCommands);
|
||||
} catch (error) {
|
||||
console.error('Failed to save user commands:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveBuiltInCommandData = async (updatedBuiltInCommands: CustomCommand[]) => {
|
||||
try {
|
||||
// Only save favorites and usage count for built-in commands
|
||||
const builtInData: Record<string, { isFavorite: boolean; usageCount: number }> = {};
|
||||
updatedBuiltInCommands.forEach(cmd => {
|
||||
builtInData[cmd.id] = {
|
||||
isFavorite: cmd.isFavorite,
|
||||
usageCount: cmd.usageCount,
|
||||
};
|
||||
});
|
||||
localStorage.setItem('goose-builtin-commands', JSON.stringify(builtInData));
|
||||
setBuiltInCommands(updatedBuiltInCommands);
|
||||
} catch (error) {
|
||||
console.error('Failed to save built-in command data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalSave = (command: CustomCommand) => {
|
||||
const now = new Date();
|
||||
let updatedUserCommands: CustomCommand[];
|
||||
|
||||
if (editingCommand && !editingCommand.isBuiltIn) {
|
||||
// Update existing user command
|
||||
updatedUserCommands = userCommands.map(cmd =>
|
||||
cmd.id === editingCommand.id
|
||||
? { ...command, id: editingCommand.id, createdAt: editingCommand.createdAt, updatedAt: now }
|
||||
: cmd
|
||||
);
|
||||
} else {
|
||||
// Create new user command
|
||||
updatedUserCommands = [...userCommands, { ...command, createdAt: now, updatedAt: now }];
|
||||
}
|
||||
|
||||
saveUserCommands(updatedUserCommands);
|
||||
};
|
||||
|
||||
const handleEdit = (command: CustomCommand) => {
|
||||
// Built-in commands cannot be edited
|
||||
if (command.isBuiltIn) {
|
||||
return;
|
||||
}
|
||||
setEditingCommand(command);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingCommand(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingCommand(null);
|
||||
};
|
||||
|
||||
const handleDelete = (commandId: string) => {
|
||||
// Built-in commands cannot be deleted
|
||||
const command = allCommands.find(cmd => cmd.id === commandId);
|
||||
if (command?.isBuiltIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm('Are you sure you want to delete this command?')) {
|
||||
const updatedUserCommands = userCommands.filter(cmd => cmd.id !== commandId);
|
||||
saveUserCommands(updatedUserCommands);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = (commandId: string) => {
|
||||
// Handle built-in commands separately
|
||||
const builtInCommand = builtInCommands.find(cmd => cmd.id === commandId);
|
||||
if (builtInCommand) {
|
||||
const updatedBuiltInCommands = builtInCommands.map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, isFavorite: !cmd.isFavorite } : cmd
|
||||
);
|
||||
saveBuiltInCommandData(updatedBuiltInCommands);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle user commands
|
||||
const updatedUserCommands = userCommands.map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, isFavorite: !cmd.isFavorite } : cmd
|
||||
);
|
||||
saveUserCommands(updatedUserCommands);
|
||||
};
|
||||
|
||||
const handleDuplicate = (command: CustomCommand) => {
|
||||
const duplicatedCommand: CustomCommand = {
|
||||
...command,
|
||||
id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `${command.name}_copy`,
|
||||
label: `${command.label} (Copy)`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 0,
|
||||
isBuiltIn: false, // Duplicates are always user commands
|
||||
};
|
||||
saveUserCommands([...userCommands, duplicatedCommand]);
|
||||
};
|
||||
|
||||
const filteredCommands = allCommands.filter(cmd => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cmd.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cmd.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center justify-between gap-4 mb-4 px-2">
|
||||
<Input
|
||||
placeholder="Search commands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="p-2">
|
||||
<Upload size={16} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="p-2">
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
<Button onClick={handleCreateNew} size="sm" className="p-2">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commands List - Row Style */}
|
||||
{filteredCommands.map(command => (
|
||||
<div key={command.id} className="group hover:cursor-pointer text-sm">
|
||||
<div className="flex items-center text-text-default py-2 px-2 bg-background-default hover:bg-background-muted rounded-lg transition-all relative">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="flex items-center justify-center text-text-muted">
|
||||
{ICON_MAP[command.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-text-default font-medium">/{command.name}</h3>
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mt-[2px]">{command.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 relative w-40">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(command.id);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
{command.isFavorite ? (
|
||||
<Star size={12} className="text-yellow-500 fill-yellow-500" />
|
||||
) : (
|
||||
<StarOff size={12} className="text-text-muted" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(command);
|
||||
}}
|
||||
disabled={command.isBuiltIn}
|
||||
className={`p-1 h-6 w-6 ${command.isBuiltIn ? 'opacity-50 cursor-not-allowed blur-sm' : ''}`}
|
||||
>
|
||||
<Edit size={12} className="text-text-muted hover:text-text-default" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate(command);
|
||||
}}
|
||||
disabled={command.isBuiltIn}
|
||||
className={`p-1 h-6 w-6 ${command.isBuiltIn ? 'opacity-50 cursor-not-allowed blur-sm' : ''}`}
|
||||
>
|
||||
<Copy size={12} className="text-text-muted hover:text-text-default" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(command.id);
|
||||
}}
|
||||
disabled={command.isBuiltIn}
|
||||
className={`p-1 h-6 w-6 ${command.isBuiltIn ? 'opacity-50 cursor-not-allowed blur-sm' : ''}`}
|
||||
>
|
||||
<Trash2 size={12} className={command.isBuiltIn ? 'text-text-muted' : 'text-red-600 hover:text-red-700'} />
|
||||
</Button>
|
||||
|
||||
{/* Built-in pill overlay */}
|
||||
{command.isBuiltIn && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none pl-2">
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 rounded-full font-medium shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
Built-in
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-background-muted rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Zap size={16} className="text-text-muted" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-text-default mb-1">
|
||||
{searchQuery ? 'No commands found' : 'No custom commands yet'}
|
||||
</h3>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
{searchQuery
|
||||
? 'Try adjusting your search query'
|
||||
: 'Create your first custom slash command to get started'
|
||||
}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus size={14} className="mr-2" />
|
||||
Create Command
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<AddCustomCommandModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleModalSave}
|
||||
editingCommand={editingCommand}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Example commands for demo - these provide useful starting points for users
|
||||
const getExampleCommands = (): CustomCommand[] => [
|
||||
{
|
||||
id: 'cmd_example_1',
|
||||
name: 'document',
|
||||
label: 'Create Document',
|
||||
description: 'Generate comprehensive documentation for code or projects',
|
||||
prompt: `Please create comprehensive documentation for the provided code or project. Include:
|
||||
|
||||
1. Overview and purpose
|
||||
2. Installation/setup instructions
|
||||
3. Usage examples
|
||||
4. API reference (if applicable)
|
||||
5. Configuration options
|
||||
6. Troubleshooting guide
|
||||
|
||||
Make the documentation clear, well-structured, and suitable for both beginners and experienced users.`,
|
||||
icon: 'FileText',
|
||||
category: 'documentation',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_2',
|
||||
name: 'review',
|
||||
label: 'Code Review',
|
||||
description: 'Perform thorough code review with suggestions and best practices',
|
||||
prompt: `Please perform a comprehensive code review of the provided code. Focus on:
|
||||
|
||||
1. Code quality and readability
|
||||
2. Performance optimizations
|
||||
3. Security considerations
|
||||
4. Best practices and conventions
|
||||
5. Potential bugs or issues
|
||||
6. Suggestions for improvement
|
||||
|
||||
Provide specific, actionable feedback with examples where appropriate.`,
|
||||
icon: 'Search',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_3',
|
||||
name: 'explain',
|
||||
label: 'Explain Code',
|
||||
description: 'Provide detailed explanation of how code works',
|
||||
prompt: `Please explain the provided code in detail. Include:
|
||||
|
||||
1. What the code does (high-level purpose)
|
||||
2. How it works (step-by-step breakdown)
|
||||
3. Key concepts and patterns used
|
||||
4. Dependencies and requirements
|
||||
5. Potential use cases
|
||||
6. Any notable design decisions
|
||||
|
||||
Make the explanation accessible to developers who may not be familiar with this specific code.`,
|
||||
icon: 'Code',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_4',
|
||||
name: 'optimize',
|
||||
label: 'Optimize Code',
|
||||
description: 'Suggest optimizations and improvements for better performance',
|
||||
prompt: `Please analyze the provided code and suggest optimizations for better performance, efficiency, and maintainability. Consider:
|
||||
|
||||
1. Algorithm efficiency and time complexity
|
||||
2. Memory usage optimization
|
||||
3. Code structure and organization
|
||||
4. Best practices for the specific language/framework
|
||||
5. Potential refactoring opportunities
|
||||
6. Performance bottlenecks
|
||||
|
||||
Provide specific, actionable recommendations with code examples where helpful.`,
|
||||
icon: 'Zap',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_5',
|
||||
name: 'test',
|
||||
label: 'Generate Tests',
|
||||
description: 'Create comprehensive unit tests for the provided code',
|
||||
prompt: `Please generate comprehensive unit tests for the provided code. Include:
|
||||
|
||||
1. Test cases for normal/expected behavior
|
||||
2. Edge cases and boundary conditions
|
||||
3. Error handling and exception cases
|
||||
4. Mock objects where appropriate
|
||||
5. Test setup and teardown if needed
|
||||
6. Clear, descriptive test names
|
||||
|
||||
Use the appropriate testing framework for the language and follow testing best practices.`,
|
||||
icon: 'Search',
|
||||
category: 'testing',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
}
|
||||
];
|
||||
|
||||
export default CustomCommandsSettings;
|
||||
@@ -5,6 +5,7 @@ import DictationSection from '../dictation/DictationSection';
|
||||
import { SecurityToggle } from '../security/SecurityToggle';
|
||||
import { ResponseStylesSection } from '../response_styles/ResponseStylesSection';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { CustomCommandsSettings } from '../CustomCommandsSettings';
|
||||
|
||||
export default function ChatSettingsSection() {
|
||||
return (
|
||||
@@ -35,6 +36,16 @@ export default function ChatSettingsSection() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="">Custom Commands</CardTitle>
|
||||
<CardDescription>Create custom slash commands with rich prompts that expand when sent to the AI</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2">
|
||||
<CustomCommandsSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<DictationSection />
|
||||
|
||||
@@ -175,10 +175,10 @@ export default function ModelsBottomBar({
|
||||
<div className="relative flex items-center" ref={dropdownRef}>
|
||||
<BottomMenuAlertPopover alerts={alerts} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center hover:cursor-pointer max-w-[180px] md:max-w-[200px] lg:max-w-[380px] min-w-0 text-text-default/70 hover:text-text-default transition-colors">
|
||||
<DropdownMenuTrigger className="flex items-center hover:cursor-pointer max-w-[180px] md:max-w-[200px] lg:max-w-[380px] min-w-0 text-text-default/70 hover:text-text-default transition-colors px-1">
|
||||
<div className="flex items-center truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0">
|
||||
<Bot className="mr-1 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate text-xs">
|
||||
<Bot className="min-[1050px]:mr-1 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate text-xs hidden min-[1050px]:inline">
|
||||
{displayModel}
|
||||
{isLeadWorkerActive && modelMode && (
|
||||
<span className="ml-1 text-[10px] opacity-60">({modelMode})</span>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { CustomCommand } from '../types/customCommands';
|
||||
|
||||
export const useCustomCommands = () => {
|
||||
const [commands, setCommands] = useState<CustomCommand[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load commands from storage
|
||||
const loadCommands = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// For now, use localStorage. In the future, this could be replaced with API calls
|
||||
const stored = localStorage.getItem('goose-custom-commands');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const commands = parsed.map((cmd: any) => ({
|
||||
...cmd,
|
||||
createdAt: new Date(cmd.createdAt),
|
||||
updatedAt: new Date(cmd.updatedAt)
|
||||
}));
|
||||
setCommands(commands);
|
||||
} else {
|
||||
// Load example commands for demo
|
||||
const exampleCommands = getExampleCommands();
|
||||
setCommands(exampleCommands);
|
||||
localStorage.setItem('goose-custom-commands', JSON.stringify(exampleCommands));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load custom commands:', err);
|
||||
setError('Failed to load custom commands');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save commands to storage
|
||||
const saveCommands = useCallback(async (updatedCommands: CustomCommand[]) => {
|
||||
try {
|
||||
localStorage.setItem('goose-custom-commands', JSON.stringify(updatedCommands));
|
||||
setCommands(updatedCommands);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to save custom commands:', err);
|
||||
setError('Failed to save custom commands');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get command by ID
|
||||
const getCommand = useCallback((id: string): CustomCommand | undefined => {
|
||||
return commands.find(cmd => cmd.id === id);
|
||||
}, [commands]);
|
||||
|
||||
// Get command by name
|
||||
const getCommandByName = useCallback((name: string): CustomCommand | undefined => {
|
||||
return commands.find(cmd => cmd.name.toLowerCase() === name.toLowerCase());
|
||||
}, [commands]);
|
||||
|
||||
// Expand a command's prompt (replace variables, etc.)
|
||||
const expandCommandPrompt = useCallback((command: CustomCommand, context?: Record<string, string>): string => {
|
||||
let expandedPrompt = command.prompt;
|
||||
|
||||
// Replace common variables if context is provided
|
||||
if (context) {
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
const placeholder = `{${key}}`;
|
||||
expandedPrompt = expandedPrompt.replace(new RegExp(placeholder, 'g'), value);
|
||||
});
|
||||
}
|
||||
|
||||
return expandedPrompt;
|
||||
}, []);
|
||||
|
||||
// Increment usage count for a command
|
||||
const incrementUsage = useCallback(async (commandId: string) => {
|
||||
const updatedCommands = commands.map(cmd =>
|
||||
cmd.id === commandId
|
||||
? { ...cmd, usageCount: cmd.usageCount + 1, updatedAt: new Date() }
|
||||
: cmd
|
||||
);
|
||||
await saveCommands(updatedCommands);
|
||||
}, [commands, saveCommands]);
|
||||
|
||||
// Add a new command
|
||||
const addCommand = useCallback(async (commandData: Omit<CustomCommand, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const newCommand: CustomCommand = {
|
||||
...commandData,
|
||||
id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const updatedCommands = [...commands, newCommand];
|
||||
await saveCommands(updatedCommands);
|
||||
return newCommand;
|
||||
}, [commands, saveCommands]);
|
||||
|
||||
// Update an existing command
|
||||
const updateCommand = useCallback(async (commandId: string, updates: Partial<CustomCommand>) => {
|
||||
const updatedCommands = commands.map(cmd =>
|
||||
cmd.id === commandId
|
||||
? { ...cmd, ...updates, updatedAt: new Date() }
|
||||
: cmd
|
||||
);
|
||||
await saveCommands(updatedCommands);
|
||||
}, [commands, saveCommands]);
|
||||
|
||||
// Delete a command
|
||||
const deleteCommand = useCallback(async (commandId: string) => {
|
||||
const updatedCommands = commands.filter(cmd => cmd.id !== commandId);
|
||||
await saveCommands(updatedCommands);
|
||||
}, [commands, saveCommands]);
|
||||
|
||||
// Toggle favorite status
|
||||
const toggleFavorite = useCallback(async (commandId: string) => {
|
||||
const updatedCommands = commands.map(cmd =>
|
||||
cmd.id === commandId
|
||||
? { ...cmd, isFavorite: !cmd.isFavorite, updatedAt: new Date() }
|
||||
: cmd
|
||||
);
|
||||
await saveCommands(updatedCommands);
|
||||
}, [commands, saveCommands]);
|
||||
|
||||
// Load commands on mount
|
||||
useEffect(() => {
|
||||
loadCommands();
|
||||
}, [loadCommands]);
|
||||
|
||||
return {
|
||||
commands,
|
||||
isLoading,
|
||||
error,
|
||||
loadCommands,
|
||||
getCommand,
|
||||
getCommandByName,
|
||||
expandCommandPrompt,
|
||||
incrementUsage,
|
||||
addCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
toggleFavorite,
|
||||
};
|
||||
};
|
||||
|
||||
// Example commands for demo
|
||||
const getExampleCommands = (): CustomCommand[] => [
|
||||
{
|
||||
id: 'cmd_example_1',
|
||||
name: 'document',
|
||||
label: 'Create Document',
|
||||
description: 'Generate comprehensive documentation for code or projects',
|
||||
prompt: `Please create comprehensive documentation for the provided code or project. Include:
|
||||
|
||||
1. Overview and purpose
|
||||
2. Installation/setup instructions
|
||||
3. Usage examples
|
||||
4. API reference (if applicable)
|
||||
5. Configuration options
|
||||
6. Troubleshooting guide
|
||||
|
||||
Make the documentation clear, well-structured, and suitable for both beginners and experienced users.`,
|
||||
icon: 'FileText',
|
||||
category: 'documentation',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 12,
|
||||
isFavorite: true,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_2',
|
||||
name: 'review',
|
||||
label: 'Code Review',
|
||||
description: 'Perform thorough code review with suggestions and best practices',
|
||||
prompt: `Please perform a comprehensive code review of the provided code. Focus on:
|
||||
|
||||
1. Code quality and readability
|
||||
2. Performance optimizations
|
||||
3. Security considerations
|
||||
4. Best practices and conventions
|
||||
5. Potential bugs or issues
|
||||
6. Suggestions for improvement
|
||||
|
||||
Provide specific, actionable feedback with examples where appropriate.`,
|
||||
icon: 'Search',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 8,
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_3',
|
||||
name: 'explain',
|
||||
label: 'Explain Code',
|
||||
description: 'Provide detailed explanation of how code works',
|
||||
prompt: `Please explain the provided code in detail. Include:
|
||||
|
||||
1. What the code does (high-level purpose)
|
||||
2. How it works (step-by-step breakdown)
|
||||
3. Key concepts and patterns used
|
||||
4. Dependencies and requirements
|
||||
5. Potential use cases
|
||||
6. Any notable design decisions
|
||||
|
||||
Make the explanation accessible to developers who may not be familiar with this specific code.`,
|
||||
icon: 'Code',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 15,
|
||||
isFavorite: true,
|
||||
},
|
||||
{
|
||||
id: 'cmd_example_4',
|
||||
name: 'optimize',
|
||||
label: 'Optimize Performance',
|
||||
description: 'Analyze and suggest performance optimizations',
|
||||
prompt: `Please analyze the provided code for performance optimization opportunities. Focus on:
|
||||
|
||||
1. Algorithmic efficiency improvements
|
||||
2. Memory usage optimization
|
||||
3. Database query optimization (if applicable)
|
||||
4. Caching strategies
|
||||
5. Bottleneck identification
|
||||
6. Scalability considerations
|
||||
|
||||
Provide specific recommendations with code examples where possible.`,
|
||||
icon: 'Zap',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: 5,
|
||||
isFavorite: false,
|
||||
}
|
||||
];
|
||||
|
||||
export default useCustomCommands;
|
||||
@@ -423,6 +423,70 @@ export function useMessageStream({
|
||||
[mutate, mutateChatState, onFinish, onError, forceUpdate, setError]
|
||||
);
|
||||
|
||||
// Function to expand custom command pills in text
|
||||
const expandCustomCommandPills = useCallback((text: string): string => {
|
||||
// Find all action pills in the format [Action Label]
|
||||
const actionPillRegex = /\[([^\]]+)\]/g;
|
||||
let expandedText = text;
|
||||
|
||||
// Replace each action pill with its corresponding prompt
|
||||
expandedText = expandedText.replace(actionPillRegex, (match, label) => {
|
||||
// Check if it's a custom command by looking for a command with this label
|
||||
try {
|
||||
const stored = localStorage.getItem('goose-custom-commands');
|
||||
if (stored) {
|
||||
const commands = JSON.parse(stored);
|
||||
const customCommand = commands.find((cmd: any) => cmd.label === label);
|
||||
|
||||
if (customCommand) {
|
||||
// Increment usage count for the custom command
|
||||
try {
|
||||
const updatedCommands = commands.map((cmd: any) =>
|
||||
cmd.id === customCommand.id
|
||||
? { ...cmd, usageCount: (cmd.usageCount || 0) + 1 }
|
||||
: cmd
|
||||
);
|
||||
localStorage.setItem('goose-custom-commands', JSON.stringify(updatedCommands));
|
||||
} catch (error) {
|
||||
console.error('Error updating usage count:', error);
|
||||
}
|
||||
|
||||
// Return the expanded prompt
|
||||
return customCommand.prompt || match;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error expanding custom command:', error);
|
||||
}
|
||||
|
||||
// If not a custom command, return the original pill (built-in actions)
|
||||
return match;
|
||||
});
|
||||
|
||||
return expandedText;
|
||||
}, []);
|
||||
|
||||
// Function to expand action pills in messages before sending to API
|
||||
const expandActionPillsInMessages = useCallback((messages: Message[]): Message[] => {
|
||||
return messages.map(message => {
|
||||
if (message.role === 'user') {
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map(content => {
|
||||
if (content.type === 'text') {
|
||||
return {
|
||||
...content,
|
||||
text: expandCustomCommandPills(content.text)
|
||||
};
|
||||
}
|
||||
return content;
|
||||
})
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}, [expandCustomCommandPills]);
|
||||
|
||||
// Send a request to the server
|
||||
const sendRequest = useCallback(
|
||||
async (requestMessages: Message[]) => {
|
||||
@@ -434,6 +498,9 @@ export function useMessageStream({
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
// Expand action pills in messages before sending to API
|
||||
const expandedMessages = expandActionPillsInMessages(requestMessages);
|
||||
|
||||
// Send request to the server
|
||||
const response = await fetch(api, {
|
||||
method: 'POST',
|
||||
@@ -443,7 +510,7 @@ export function useMessageStream({
|
||||
...extraMetadataRef.current.headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: requestMessages,
|
||||
messages: expandedMessages,
|
||||
...extraMetadataRef.current.body,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
@@ -510,7 +577,7 @@ export function useMessageStream({
|
||||
}
|
||||
},
|
||||
|
||||
[api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps]
|
||||
[api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps, expandActionPillsInMessages]
|
||||
);
|
||||
|
||||
// Append a new message and send request
|
||||
|
||||
@@ -2324,6 +2324,301 @@ async function appMain() {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle spell checking requests using system spell checker
|
||||
ipcMain.handle('spell-check', async (event, word: string) => {
|
||||
try {
|
||||
console.log('[Main] System spell check request for word:', word);
|
||||
|
||||
if (!word || typeof word !== 'string') {
|
||||
return true; // Assume correct for invalid input
|
||||
}
|
||||
|
||||
// Skip very short words (less than 3 characters)
|
||||
if (word.length < 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cleanWord = word.trim();
|
||||
|
||||
try {
|
||||
// Use system spell checker based on platform
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS: Use aspell
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const aspellProcess = spawn('aspell', ['-a'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
aspellProcess.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
aspellProcess.on('close', (code) => {
|
||||
// Parse aspell output
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
let isCorrect = true;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('*')) {
|
||||
// Word is correct
|
||||
isCorrect = true;
|
||||
break;
|
||||
} else if (line.startsWith('&') || line.startsWith('#')) {
|
||||
// Word is misspelled
|
||||
isCorrect = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Main] macOS aspell spell check result for', word, ':', isCorrect);
|
||||
resolve(isCorrect);
|
||||
});
|
||||
|
||||
aspellProcess.on('error', (error) => {
|
||||
console.error('[Main] aspell error:', error);
|
||||
resolve(true); // Default to correct if aspell not available
|
||||
});
|
||||
|
||||
aspellProcess.stdin.write(cleanWord + '\n');
|
||||
aspellProcess.stdin.end();
|
||||
|
||||
setTimeout(() => {
|
||||
aspellProcess.kill();
|
||||
resolve(true);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
} else if (process.platform === 'win32') {
|
||||
// Windows: Try to use hunspell or fall back to basic check
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Try hunspell first (if available)
|
||||
const hunspellProcess = spawn('hunspell', ['-d', 'en_US'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
hunspellProcess.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
hunspellProcess.on('close', (code) => {
|
||||
// hunspell returns "*" for correct words, "&" for incorrect
|
||||
const isCorrect = output.includes('*') || output.trim() === '';
|
||||
console.log('[Main] Windows spell check result for', word, ':', isCorrect);
|
||||
resolve(isCorrect);
|
||||
});
|
||||
|
||||
hunspellProcess.on('error', (error) => {
|
||||
console.error('[Main] hunspell not available, defaulting to correct:', error);
|
||||
resolve(true); // Default to correct if hunspell not available
|
||||
});
|
||||
|
||||
hunspellProcess.stdin.write(cleanWord + '\n');
|
||||
hunspellProcess.stdin.end();
|
||||
|
||||
setTimeout(() => {
|
||||
hunspellProcess.kill();
|
||||
resolve(true);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
} else {
|
||||
// Linux: Use aspell or hunspell
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const aspellProcess = spawn('aspell', ['-a'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
aspellProcess.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
aspellProcess.on('close', (code) => {
|
||||
// Parse aspell output
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
let isCorrect = true;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('*')) {
|
||||
isCorrect = true;
|
||||
break;
|
||||
} else if (line.startsWith('&') || line.startsWith('#')) {
|
||||
isCorrect = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Main] Linux spell check result for', word, ':', isCorrect);
|
||||
resolve(isCorrect);
|
||||
});
|
||||
|
||||
aspellProcess.on('error', (error) => {
|
||||
console.error('[Main] aspell error:', error);
|
||||
resolve(true); // Default to correct if aspell not available
|
||||
});
|
||||
|
||||
aspellProcess.stdin.write(cleanWord + '\n');
|
||||
aspellProcess.stdin.end();
|
||||
|
||||
setTimeout(() => {
|
||||
aspellProcess.kill();
|
||||
resolve(true);
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Main] Error using system spell checker:', error);
|
||||
return true; // Default to correct on error
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in system spell-check handler:', error);
|
||||
return true; // Assume correct on error
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('spell-suggestions', async (event, word: string) => {
|
||||
try {
|
||||
console.log('[Main] System spell suggestions request for word:', word);
|
||||
|
||||
if (!word || typeof word !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Skip very short words
|
||||
if (word.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cleanWord = word.trim();
|
||||
|
||||
try {
|
||||
// Get suggestions using system spell checker based on platform
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
// macOS and Linux: Use aspell for suggestions
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const aspellProcess = spawn('aspell', ['-a'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
aspellProcess.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
aspellProcess.on('close', (code) => {
|
||||
// Parse aspell output for suggestions
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
let suggestions: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('&')) {
|
||||
// Line format: & word count offset: suggestion1, suggestion2, ...
|
||||
const parts = line.split(':');
|
||||
if (parts.length > 1) {
|
||||
const suggestionsPart = parts[1].trim();
|
||||
suggestions = suggestionsPart.split(',').map(s => s.trim()).slice(0, 5); // Limit to 5 suggestions
|
||||
}
|
||||
break;
|
||||
} else if (line.startsWith('#')) {
|
||||
// No suggestions available
|
||||
suggestions = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Main] aspell spell suggestions for', word, ':', suggestions);
|
||||
resolve(suggestions);
|
||||
});
|
||||
|
||||
aspellProcess.on('error', (error) => {
|
||||
console.error('[Main] aspell error getting suggestions:', error);
|
||||
resolve([]); // Return empty array on error
|
||||
});
|
||||
|
||||
aspellProcess.stdin.write(cleanWord + '\n');
|
||||
aspellProcess.stdin.end();
|
||||
|
||||
setTimeout(() => {
|
||||
aspellProcess.kill();
|
||||
resolve([]);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
} else if (process.platform === 'win32') {
|
||||
// Windows: Try to use hunspell for suggestions
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const hunspellProcess = spawn('hunspell', ['-d', 'en_US', '-s'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
hunspellProcess.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
hunspellProcess.on('close', (code) => {
|
||||
// Parse hunspell suggestions
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
const suggestions = lines.slice(0, 5); // Limit to 5 suggestions
|
||||
|
||||
console.log('[Main] hunspell spell suggestions for', word, ':', suggestions);
|
||||
resolve(suggestions);
|
||||
});
|
||||
|
||||
hunspellProcess.on('error', (error) => {
|
||||
console.error('[Main] hunspell not available for suggestions:', error);
|
||||
resolve([]); // Return empty array if hunspell not available
|
||||
});
|
||||
|
||||
hunspellProcess.stdin.write(cleanWord + '\n');
|
||||
hunspellProcess.stdin.end();
|
||||
|
||||
setTimeout(() => {
|
||||
hunspellProcess.kill();
|
||||
resolve([]);
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Main] Error getting spell suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in system spell-suggestions handler:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
|
||||
@@ -120,6 +120,9 @@ type ElectronAPI = {
|
||||
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise<boolean>;
|
||||
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
|
||||
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
|
||||
// Spell checking functions
|
||||
spellCheck: (word: string) => Promise<boolean>;
|
||||
spellSuggestions: (word: string) => Promise<string[]>;
|
||||
};
|
||||
|
||||
type AppConfigAPI = {
|
||||
@@ -256,6 +259,9 @@ const electronAPI: ElectronAPI = {
|
||||
recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe),
|
||||
openDirectoryInExplorer: (directoryPath: string) =>
|
||||
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
|
||||
// Spell checking functions
|
||||
spellCheck: (word: string) => ipcRenderer.invoke('spell-check', word),
|
||||
spellSuggestions: (word: string) => ipcRenderer.invoke('spell-suggestions', word),
|
||||
};
|
||||
|
||||
const appConfigAPI: AppConfigAPI = {
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
|
||||
@theme inline {
|
||||
--ease-g2: cubic-bezier(0.55, 0, 1, 0.45);
|
||||
|
||||
--animate-blink: blink 1s step-end infinite;
|
||||
|
||||
--color-background-default: var(--background-default);
|
||||
--color-background-muted: var(--background-muted);
|
||||
@@ -290,6 +292,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -767,3 +778,27 @@ p > code.bg-inline-code {
|
||||
.animate-shimmer {
|
||||
animation: shimmer 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
/* Enhanced text selection styling */
|
||||
.rich-text-input textarea::selection {
|
||||
background-color: #3b82f6 !important; /* Blue-500 with !important */
|
||||
color: white !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.rich-text-input textarea::-moz-selection {
|
||||
background-color: #3b82f6 !important; /* Blue-500 with !important */
|
||||
color: white !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Make sure selection is visible over visual content */
|
||||
.rich-text-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rich-text-input textarea {
|
||||
mix-blend-mode: multiply; /* Blend with background for better visibility */
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
export interface CustomCommand {
|
||||
id: string;
|
||||
name: string; // The command name (e.g., "document", "review")
|
||||
label: string; // Display name (e.g., "Create Document", "Code Review")
|
||||
description: string; // Short description for the popover
|
||||
prompt: string; // The full prompt template that gets sent to the LLM
|
||||
icon?: string; // Optional icon name or emoji
|
||||
category?: string; // Optional category for grouping
|
||||
variables?: CustomCommandVariable[]; // Template variables
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
usageCount: number; // Track how often it's used
|
||||
isFavorite: boolean; // User can mark favorites
|
||||
isBuiltIn?: boolean; // Built-in commands cannot be deleted, only favorited
|
||||
}
|
||||
|
||||
export interface CustomCommandVariable {
|
||||
name: string; // Variable name (e.g., "filename", "selection")
|
||||
label: string; // Display label
|
||||
description: string; // Help text
|
||||
type: 'text' | 'selection' | 'filename' | 'directory' | 'custom';
|
||||
required: boolean;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface CustomCommandCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string; // Hex color for visual grouping
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface CustomCommandsState {
|
||||
commands: CustomCommand[];
|
||||
categories: CustomCommandCategory[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Built-in command categories
|
||||
export const DEFAULT_CATEGORIES: CustomCommandCategory[] = [
|
||||
{
|
||||
id: 'general',
|
||||
name: 'General',
|
||||
description: 'General purpose commands',
|
||||
color: '#6B7280',
|
||||
icon: 'Zap'
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
description: 'Code and development related commands',
|
||||
color: '#3B82F6',
|
||||
icon: 'Code'
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
name: 'Documentation',
|
||||
description: 'Documentation and writing commands',
|
||||
color: '#10B981',
|
||||
icon: 'FileText'
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
name: 'Analysis',
|
||||
description: 'Analysis and review commands',
|
||||
color: '#8B5CF6',
|
||||
icon: 'Search'
|
||||
}
|
||||
];
|
||||
|
||||
// Template for creating new commands
|
||||
export const COMMAND_TEMPLATE: Omit<CustomCommand, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
name: '',
|
||||
label: '',
|
||||
description: '',
|
||||
prompt: '',
|
||||
icon: 'Zap',
|
||||
category: 'general',
|
||||
variables: [],
|
||||
usageCount: 0,
|
||||
isFavorite: false
|
||||
};
|
||||
|
||||
// Validation rules
|
||||
export const COMMAND_VALIDATION = {
|
||||
name: {
|
||||
minLength: 2,
|
||||
maxLength: 20,
|
||||
pattern: /^[a-z][a-z0-9]*$/i, // Must start with letter, only alphanumeric
|
||||
},
|
||||
label: {
|
||||
minLength: 3,
|
||||
maxLength: 50,
|
||||
},
|
||||
description: {
|
||||
minLength: 10,
|
||||
maxLength: 200,
|
||||
},
|
||||
prompt: {
|
||||
minLength: 10,
|
||||
maxLength: 5000,
|
||||
}
|
||||
};
|
||||
|
||||
// Common variable templates
|
||||
export const COMMON_VARIABLES: CustomCommandVariable[] = [
|
||||
{
|
||||
name: 'selection',
|
||||
label: 'Selected Text',
|
||||
description: 'Currently selected text in the editor',
|
||||
type: 'selection',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'filename',
|
||||
label: 'Current File',
|
||||
description: 'Name of the currently active file',
|
||||
type: 'filename',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'directory',
|
||||
label: 'Current Directory',
|
||||
description: 'Current working directory path',
|
||||
type: 'directory',
|
||||
required: false,
|
||||
}
|
||||
];
|
||||
|
||||
// Built-in commands that cannot be deleted, only favorited
|
||||
export const BUILT_IN_COMMANDS: CustomCommand[] = [
|
||||
{
|
||||
id: 'builtin_explain',
|
||||
name: 'explain',
|
||||
label: 'Explain Code',
|
||||
description: 'Provide detailed explanation of code or concepts',
|
||||
prompt: `Please explain the provided code or concept in detail. Include:
|
||||
|
||||
1. What it does (high-level purpose)
|
||||
2. How it works (step-by-step breakdown)
|
||||
3. Key concepts and patterns used
|
||||
4. Dependencies and requirements
|
||||
5. Potential use cases
|
||||
6. Any notable design decisions
|
||||
|
||||
Make the explanation accessible and comprehensive.`,
|
||||
icon: 'Search',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin_review',
|
||||
name: 'review',
|
||||
label: 'Code Review',
|
||||
description: 'Perform thorough code review with suggestions',
|
||||
prompt: `Please perform a comprehensive code review. Focus on:
|
||||
|
||||
1. Code quality and readability
|
||||
2. Performance optimizations
|
||||
3. Security considerations
|
||||
4. Best practices and conventions
|
||||
5. Potential bugs or issues
|
||||
6. Suggestions for improvement
|
||||
|
||||
Provide specific, actionable feedback with examples where appropriate.`,
|
||||
icon: 'Search',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin_document',
|
||||
name: 'document',
|
||||
label: 'Create Documentation',
|
||||
description: 'Generate comprehensive documentation',
|
||||
prompt: `Please create comprehensive documentation for the provided code or project. Include:
|
||||
|
||||
1. Overview and purpose
|
||||
2. Installation/setup instructions
|
||||
3. Usage examples
|
||||
4. API reference (if applicable)
|
||||
5. Configuration options
|
||||
6. Troubleshooting guide
|
||||
|
||||
Make the documentation clear, well-structured, and suitable for both beginners and experienced users.`,
|
||||
icon: 'FileText',
|
||||
category: 'documentation',
|
||||
variables: [],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin_optimize',
|
||||
name: 'optimize',
|
||||
label: 'Optimize Code',
|
||||
description: 'Suggest optimizations for better performance',
|
||||
prompt: `Please analyze the provided code and suggest optimizations for better performance, efficiency, and maintainability. Consider:
|
||||
|
||||
1. Algorithm efficiency and time complexity
|
||||
2. Memory usage optimization
|
||||
3. Code structure and organization
|
||||
4. Best practices for the specific language/framework
|
||||
5. Potential refactoring opportunities
|
||||
6. Performance bottlenecks
|
||||
|
||||
Provide specific, actionable recommendations with code examples where helpful.`,
|
||||
icon: 'Zap',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin_test',
|
||||
name: 'test',
|
||||
label: 'Generate Tests',
|
||||
description: 'Create comprehensive unit tests',
|
||||
prompt: `Please generate comprehensive unit tests for the provided code. Include:
|
||||
|
||||
1. Test cases for normal/expected behavior
|
||||
2. Edge cases and boundary conditions
|
||||
3. Error handling and exception cases
|
||||
4. Mock objects where appropriate
|
||||
5. Test setup and teardown if needed
|
||||
6. Clear, descriptive test names
|
||||
|
||||
Use the appropriate testing framework for the language and follow testing best practices.`,
|
||||
icon: 'Code',
|
||||
category: 'development',
|
||||
variables: [],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'builtin_summarize',
|
||||
name: 'summarize',
|
||||
label: 'Summarize Content',
|
||||
description: 'Create concise summary of content',
|
||||
prompt: `Please create a concise summary of the provided content. Include:
|
||||
|
||||
1. Main points and key takeaways
|
||||
2. Important details and findings
|
||||
3. Conclusions or recommendations
|
||||
4. Action items (if applicable)
|
||||
|
||||
Keep the summary clear, well-organized, and focused on the most important information.`,
|
||||
icon: 'FileText',
|
||||
category: 'general',
|
||||
variables: [],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
usageCount: 0,
|
||||
isFavorite: false,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,92 @@
|
||||
// Example of how to use system dictionary in Electron
|
||||
// This would go in your Electron main process
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
class ElectronSpellChecker {
|
||||
private wordSet: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.loadSystemDictionary();
|
||||
this.setupIPC();
|
||||
}
|
||||
|
||||
private loadSystemDictionary() {
|
||||
try {
|
||||
// Read the system dictionary
|
||||
const dictPath = '/usr/share/dict/words';
|
||||
const content = readFileSync(dictPath, 'utf8');
|
||||
const words = content.split('\n').filter(word => word.length > 0);
|
||||
|
||||
// Create a Set for fast lookups
|
||||
words.forEach(word => {
|
||||
this.wordSet.add(word.toLowerCase());
|
||||
});
|
||||
|
||||
console.log(`Loaded ${this.wordSet.size} words from system dictionary`);
|
||||
} catch (error) {
|
||||
console.error('Failed to load system dictionary:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupIPC() {
|
||||
// Handle spell check requests from renderer process
|
||||
ipcMain.handle('spell-check', (event, word: string) => {
|
||||
return this.isWordCorrect(word);
|
||||
});
|
||||
|
||||
ipcMain.handle('spell-suggestions', (event, word: string) => {
|
||||
return this.getSuggestions(word);
|
||||
});
|
||||
}
|
||||
|
||||
isWordCorrect(word: string): boolean {
|
||||
const cleanWord = word.toLowerCase().replace(/[^a-z]/g, '');
|
||||
return this.wordSet.has(cleanWord);
|
||||
}
|
||||
|
||||
getSuggestions(word: string): string[] {
|
||||
// Simple edit distance suggestions
|
||||
const suggestions: string[] = [];
|
||||
const cleanWord = word.toLowerCase();
|
||||
|
||||
// Find words with edit distance of 1
|
||||
for (const dictWord of this.wordSet) {
|
||||
if (this.editDistance(cleanWord, dictWord) === 1) {
|
||||
suggestions.push(dictWord);
|
||||
if (suggestions.length >= 5) break; // Limit suggestions
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private editDistance(a: string, b: string): number {
|
||||
if (Math.abs(a.length - b.length) > 1) return 2; // Quick optimization
|
||||
|
||||
const matrix = Array(a.length + 1).fill(null).map(() => Array(b.length + 1).fill(0));
|
||||
|
||||
for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
|
||||
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1, // deletion
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j - 1] + 1 // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[a.length][b.length];
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize in main process
|
||||
export const electronSpellChecker = new ElectronSpellChecker();
|
||||
@@ -0,0 +1,82 @@
|
||||
// Renderer process side of Electron spell checking
|
||||
// This would be used in your React components
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
spellCheck: (word: string) => Promise<boolean>;
|
||||
spellSuggestions: (word: string) => Promise<string[]>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ElectronSpellCheckRenderer {
|
||||
async isWordCorrect(word: string): Promise<boolean> {
|
||||
if (!window.electronAPI) {
|
||||
console.warn('Electron API not available, falling back to browser spell check');
|
||||
return true; // Fallback
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electronAPI.spellCheck(word);
|
||||
} catch (error) {
|
||||
console.error('Electron spell check failed:', error);
|
||||
return true; // Fallback to assuming correct
|
||||
}
|
||||
}
|
||||
|
||||
async getSuggestions(word: string): Promise<string[]> {
|
||||
if (!window.electronAPI) {
|
||||
return []; // No suggestions available
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electronAPI.spellSuggestions(word);
|
||||
} catch (error) {
|
||||
console.error('Electron spell suggestions failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const electronSpellCheckRenderer = new ElectronSpellCheckRenderer();
|
||||
|
||||
// Spell check function that works with the existing interface
|
||||
export async function checkSpelling(text: string): Promise<Array<{ word: string; start: number; end: number; suggestions: string[] }>> {
|
||||
const errors: Array<{ word: string; start: number; end: number; suggestions: string[] }> = [];
|
||||
|
||||
// Split text into words while preserving positions
|
||||
const wordRegex = /\b[a-zA-Z]+\b/g;
|
||||
let match;
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
const word = match[0];
|
||||
const start = match.index;
|
||||
const end = start + word.length;
|
||||
|
||||
// Check each word asynchronously
|
||||
promises.push(
|
||||
electronSpellCheckRenderer.isWordCorrect(word).then(async (isCorrect) => {
|
||||
if (!isCorrect) {
|
||||
const suggestions = await electronSpellCheckRenderer.getSuggestions(word);
|
||||
errors.push({
|
||||
word,
|
||||
start,
|
||||
end,
|
||||
suggestions
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all checks to complete
|
||||
await Promise.all(promises);
|
||||
|
||||
// Sort errors by position
|
||||
errors.sort((a, b) => a.start - b.start);
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Native spell checker using system dictionary
|
||||
class NativeSpellChecker {
|
||||
private wordSet: Set<string> = new Set();
|
||||
private isLoaded = false;
|
||||
|
||||
async loadDictionary(): Promise<void> {
|
||||
if (this.isLoaded) return;
|
||||
|
||||
try {
|
||||
// In a real Electron app, you'd use Node.js fs to read the file
|
||||
// For now, we'll use fetch to load a word list
|
||||
// This is a placeholder - in production you'd need to expose the system dict via Electron's main process
|
||||
|
||||
// For demonstration, let's use a smaller word list or the browser's API
|
||||
// In a full Electron implementation, you'd read /usr/share/dict/words
|
||||
|
||||
// Fallback to browser's native spell check capability
|
||||
this.isLoaded = true;
|
||||
console.log('Native spell checker loaded (using browser fallback)');
|
||||
} catch (error) {
|
||||
console.error('Failed to load native dictionary:', error);
|
||||
this.isLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
isWordCorrect(word: string): boolean {
|
||||
if (!this.isLoaded) return true; // Don't mark as incorrect if not loaded
|
||||
|
||||
// Clean the word
|
||||
const cleanWord = word.toLowerCase().replace(/[^a-z]/g, '');
|
||||
if (cleanWord.length < 2) return true;
|
||||
|
||||
// For now, use a heuristic approach since we can't easily access the system dict from browser
|
||||
// In a full Electron app, you'd check: return this.wordSet.has(cleanWord);
|
||||
|
||||
// Simple heuristic checks
|
||||
if (this.isObviouslyCorrect(cleanWord)) return true;
|
||||
if (this.isObviouslyIncorrect(cleanWord)) return false;
|
||||
|
||||
// Default to correct for unknown words
|
||||
return true;
|
||||
}
|
||||
|
||||
private isObviouslyCorrect(word: string): boolean {
|
||||
// Common words that are definitely correct
|
||||
const commonWords = new Set([
|
||||
'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'i', 'it', 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at',
|
||||
'this', 'but', 'his', 'by', 'from', 'they', 'we', 'say', 'her', 'she', 'or', 'an', 'will', 'my', 'one', 'all', 'would', 'there', 'their',
|
||||
'what', 'so', 'up', 'out', 'if', 'about', 'who', 'get', 'which', 'go', 'me', 'when', 'make', 'can', 'like', 'time', 'no', 'just', 'him',
|
||||
'know', 'take', 'people', 'into', 'year', 'your', 'good', 'some', 'could', 'them', 'see', 'other', 'than', 'then', 'now', 'look', 'only',
|
||||
'come', 'its', 'over', 'think', 'also', 'back', 'after', 'use', 'two', 'how', 'our', 'work', 'first', 'well', 'way', 'even', 'new', 'want',
|
||||
'because', 'any', 'these', 'give', 'day', 'most', 'us', 'is', 'was', 'are', 'been', 'has', 'had', 'were', 'said', 'each', 'which', 'their',
|
||||
'said', 'them', 'she', 'many', 'some', 'very', 'when', 'much', 'before', 'right', 'too', 'means', 'old', 'any', 'same', 'tell', 'boy', 'follow',
|
||||
'came', 'want', 'show', 'also', 'around', 'farm', 'three', 'small', 'set', 'put', 'end', 'why', 'again', 'turn', 'here', 'off', 'went', 'old',
|
||||
'number', 'great', 'tell', 'men', 'say', 'small', 'every', 'found', 'still', 'between', 'name', 'should', 'home', 'big', 'give', 'air', 'line',
|
||||
'where', 'much', 'too', 'means', 'old', 'any', 'same', 'tell', 'boy', 'follow', 'came', 'want', 'show'
|
||||
]);
|
||||
|
||||
return commonWords.has(word);
|
||||
}
|
||||
|
||||
private isObviouslyIncorrect(word: string): boolean {
|
||||
// Patterns that are likely incorrect
|
||||
if (word.length < 2) return false;
|
||||
|
||||
// Multiple consecutive same letters (more than 2)
|
||||
if (/(.)\1{2,}/.test(word)) return true;
|
||||
|
||||
// Too many consonants in a row
|
||||
if (/[bcdfghjklmnpqrstvwxyz]{5,}/.test(word)) return true;
|
||||
|
||||
// Starts or ends with unlikely combinations
|
||||
if (/^[qxz]/.test(word) && word.length < 4) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getSuggestions(word: string): string[] {
|
||||
// Simple suggestions based on common typos
|
||||
const suggestions: string[] = [];
|
||||
const cleanWord = word.toLowerCase();
|
||||
|
||||
// Common corrections
|
||||
const corrections: { [key: string]: string[] } = {
|
||||
'teh': ['the'],
|
||||
'adn': ['and'],
|
||||
'recieve': ['receive'],
|
||||
'seperate': ['separate'],
|
||||
'definately': ['definitely'],
|
||||
'occured': ['occurred'],
|
||||
'begining': ['beginning'],
|
||||
'existance': ['existence'],
|
||||
'independant': ['independent'],
|
||||
'neccessary': ['necessary'],
|
||||
'priviledge': ['privilege'],
|
||||
'recomend': ['recommend'],
|
||||
'succesful': ['successful'],
|
||||
'tommorow': ['tomorrow'],
|
||||
'truely': ['truly'],
|
||||
'untill': ['until'],
|
||||
'wierd': ['weird'],
|
||||
'acheive': ['achieve'],
|
||||
'beleive': ['believe'],
|
||||
'concious': ['conscious'],
|
||||
'embarass': ['embarrass'],
|
||||
'fourty': ['forty'],
|
||||
'goverment': ['government'],
|
||||
'harrass': ['harass'],
|
||||
'occassion': ['occasion'],
|
||||
'posession': ['possession'],
|
||||
'publically': ['publicly'],
|
||||
'reccomend': ['recommend'],
|
||||
'supercede': ['supersede'],
|
||||
'thier': ['their'],
|
||||
'truley': ['truly']
|
||||
};
|
||||
|
||||
if (corrections[cleanWord]) {
|
||||
suggestions.push(...corrections[cleanWord]);
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nativeSpellChecker = new NativeSpellChecker();
|
||||
|
||||
// Initialize on import
|
||||
nativeSpellChecker.loadDictionary();
|
||||
|
||||
export function checkSpelling(text: string): Array<{ word: string; start: number; end: number; suggestions: string[] }> {
|
||||
const errors: Array<{ word: string; start: number; end: number; suggestions: string[] }> = [];
|
||||
|
||||
// Split text into words while preserving positions
|
||||
const wordRegex = /\b[a-zA-Z]+\b/g;
|
||||
let match;
|
||||
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
const word = match[0];
|
||||
const start = match.index;
|
||||
const end = start + word.length;
|
||||
|
||||
if (!nativeSpellChecker.isWordCorrect(word)) {
|
||||
errors.push({
|
||||
word,
|
||||
start,
|
||||
end,
|
||||
suggestions: nativeSpellChecker.getSuggestions(word)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Smart spell checking using heuristics and patterns
|
||||
export interface MisspelledWord {
|
||||
word: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export const checkSpelling = async (text: string): Promise<MisspelledWord[]> => {
|
||||
const misspelledWords: MisspelledWord[] = [];
|
||||
|
||||
// Split text into words while preserving positions
|
||||
const words = text.split(/(\s+|[^\w\s])/);
|
||||
let currentPos = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const cleanWord = word.toLowerCase().replace(/[^\w]/g, '');
|
||||
|
||||
// Skip very short words, numbers, and common abbreviations
|
||||
if (cleanWord.length < 3 || /^\d+$/.test(cleanWord)) {
|
||||
currentPos += word.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip common programming terms, file extensions, and technical words
|
||||
const technicalWords = [
|
||||
'api', 'url', 'http', 'https', 'json', 'xml', 'css', 'html', 'js', 'ts', 'jsx', 'tsx',
|
||||
'npm', 'git', 'cli', 'ui', 'ux', 'db', 'sql', 'dev', 'prod', 'env', 'config', 'src',
|
||||
'app', 'web', 'www', 'com', 'org', 'net', 'io', 'ai', 'ml', 'gpu', 'cpu', 'ram',
|
||||
'github', 'gitlab', 'docker', 'aws', 'gcp', 'azure', 'k8s', 'oauth', 'jwt', 'cors',
|
||||
'goose', 'chat', 'llm', 'gpt', 'claude', 'openai', 'anthropic', 'react', 'node',
|
||||
'typescript', 'javascript', 'python', 'rust', 'java', 'cpp', 'csharp', 'php', 'ruby'
|
||||
];
|
||||
|
||||
if (technicalWords.includes(cleanWord)) {
|
||||
currentPos += word.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use heuristic-based spell checking
|
||||
let isMisspelled = false;
|
||||
|
||||
// Check for common patterns of misspellings
|
||||
if (
|
||||
// Common misspelling patterns
|
||||
/seperat/.test(cleanWord) ||
|
||||
/reciev/.test(cleanWord) ||
|
||||
/occas/.test(cleanWord) ||
|
||||
/necess/.test(cleanWord) ||
|
||||
/definat/.test(cleanWord) ||
|
||||
/beginn/.test(cleanWord) ||
|
||||
/accom/.test(cleanWord) ||
|
||||
/existanc/.test(cleanWord) ||
|
||||
/maintainanc/.test(cleanWord) ||
|
||||
/enviroment/.test(cleanWord) ||
|
||||
/goverment/.test(cleanWord) ||
|
||||
/independant/.test(cleanWord) ||
|
||||
/priviledge/.test(cleanWord) ||
|
||||
/sucessful/.test(cleanWord) ||
|
||||
/untill/.test(cleanWord) ||
|
||||
(/wether/.test(cleanWord) && cleanWord !== 'whether') ||
|
||||
// Test words for debugging
|
||||
cleanWord === 'sdd' || cleanWord === 'asdf' || cleanWord === 'qwerty' ||
|
||||
cleanWord === 'teh' || cleanWord === 'alot' || cleanWord === 'wierd' ||
|
||||
cleanWord === 'freind' || cleanWord === 'thier' || cleanWord === 'calender'
|
||||
) {
|
||||
isMisspelled = true;
|
||||
}
|
||||
|
||||
// Additional check: words with unusual letter combinations
|
||||
if (!isMisspelled && cleanWord.length > 4) {
|
||||
// Check for unusual patterns
|
||||
const doubleLetterPattern = /(.)\1{2,}/; // Three or more of the same letter
|
||||
const tooManyConsonants = /[bcdfgjklmnpqrstvwxz]{4,}/; // 4+ consonants in a row
|
||||
const tooManyVowels = /[aeiou]{4,}/; // 4+ vowels in a row
|
||||
const qWithoutU = /q(?!u)/; // Q not followed by U
|
||||
const multipleXYZ = /[xyz]{2,}/; // Multiple x, y, or z
|
||||
|
||||
if (
|
||||
doubleLetterPattern.test(cleanWord) ||
|
||||
tooManyConsonants.test(cleanWord) ||
|
||||
tooManyVowels.test(cleanWord) ||
|
||||
qWithoutU.test(cleanWord) ||
|
||||
multipleXYZ.test(cleanWord)
|
||||
) {
|
||||
isMisspelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMisspelled) {
|
||||
console.log('🔍 SPELL CHECK: Found misspelling!', cleanWord);
|
||||
const start = text.indexOf(word, currentPos);
|
||||
if (start !== -1) {
|
||||
misspelledWords.push({
|
||||
word: word,
|
||||
start: start,
|
||||
end: start + word.length
|
||||
});
|
||||
console.log('🔍 SPELL CHECK: Added to misspelled array:', { word, start, end: start + word.length });
|
||||
}
|
||||
}
|
||||
|
||||
currentPos += word.length;
|
||||
}
|
||||
|
||||
return misspelledWords;
|
||||
};
|
||||
Reference in New Issue
Block a user