feat: new text input with rich content

This commit is contained in:
Spence
2025-11-07 10:23:47 -05:00
committed by Alex Hancock
parent fe73f79353
commit 8a473416f2
30 changed files with 4740 additions and 243 deletions
+15
View File
@@ -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",
+2
View File
@@ -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",
+91
View File
@@ -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;
+359
View File
@@ -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
+91
View File
@@ -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;
+33 -8
View File
@@ -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;
+17 -4
View File
@@ -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>
+243
View File
@@ -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;
+69 -2
View File
@@ -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
+295
View File
@@ -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 () => {
+6
View File
@@ -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 = {
+35
View File
@@ -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 */
}
+276
View File
@@ -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;
}
+155
View File
@@ -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;
}
+106
View File
@@ -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;
};