From 8a473416f2da5d74ffc422aeb684217f8a81e273 Mon Sep 17 00:00:00 2001 From: Spence Date: Fri, 7 Nov 2025 10:23:47 -0500 Subject: [PATCH] feat: new text input with rich content --- ui/desktop/package-lock.json | 15 + ui/desktop/package.json | 2 + ui/desktop/src/components/ActionPill.tsx | 91 ++ ui/desktop/src/components/ActionPopover.tsx | 359 +++++ .../src/components/AddCustomCommandModal.tsx | 282 ++++ ui/desktop/src/components/ChatInput.tsx | 607 +++++--- ui/desktop/src/components/MentionPill.tsx | 91 ++ ui/desktop/src/components/MentionPopover.tsx | 41 +- ui/desktop/src/components/MessageContent.tsx | 197 +++ ui/desktop/src/components/RichChatInput.tsx | 1222 +++++++++++++++++ .../src/components/SpellCheckTooltip.tsx | 171 +++ ui/desktop/src/components/UserMessage.tsx | 21 +- .../bottom_menu/BottomMenuModeSelection.tsx | 8 +- .../components/bottom_menu/CostTracker.tsx | 12 +- .../components/bottom_menu/DirSwitcher.tsx | 6 +- ui/desktop/src/components/icons/Action.tsx | 28 + ui/desktop/src/components/icons/index.tsx | 2 + .../settings/CustomCommandsSettings.tsx | 450 ++++++ .../settings/chat/ChatSettingsSection.tsx | 11 + .../models/bottom_bar/ModelsBottomBar.tsx | 6 +- ui/desktop/src/hooks/useCustomCommands.ts | 243 ++++ ui/desktop/src/hooks/useMessageStream.ts | 71 +- ui/desktop/src/main.ts | 295 ++++ ui/desktop/src/preload.ts | 6 + ui/desktop/src/styles/main.css | 35 + ui/desktop/src/types/customCommands.ts | 276 ++++ ui/desktop/src/utils/electronSpellCheck.ts | 92 ++ .../src/utils/electronSpellCheckRenderer.ts | 82 ++ ui/desktop/src/utils/nativeSpellCheck.ts | 155 +++ ui/desktop/src/utils/smartSpellCheck.ts | 106 ++ 30 files changed, 4740 insertions(+), 243 deletions(-) create mode 100644 ui/desktop/src/components/ActionPill.tsx create mode 100644 ui/desktop/src/components/ActionPopover.tsx create mode 100644 ui/desktop/src/components/AddCustomCommandModal.tsx create mode 100644 ui/desktop/src/components/MentionPill.tsx create mode 100644 ui/desktop/src/components/MessageContent.tsx create mode 100644 ui/desktop/src/components/RichChatInput.tsx create mode 100644 ui/desktop/src/components/SpellCheckTooltip.tsx create mode 100644 ui/desktop/src/components/icons/Action.tsx create mode 100644 ui/desktop/src/components/settings/CustomCommandsSettings.tsx create mode 100644 ui/desktop/src/hooks/useCustomCommands.ts create mode 100644 ui/desktop/src/types/customCommands.ts create mode 100644 ui/desktop/src/utils/electronSpellCheck.ts create mode 100644 ui/desktop/src/utils/electronSpellCheckRenderer.ts create mode 100644 ui/desktop/src/utils/nativeSpellCheck.ts create mode 100644 ui/desktop/src/utils/smartSpellCheck.ts diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 897a31bdae..971f75f97c 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -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", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 8952fce455..697066cdca 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -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", diff --git a/ui/desktop/src/components/ActionPill.tsx b/ui/desktop/src/components/ActionPill.tsx new file mode 100644 index 0000000000..c1a1ce92cf --- /dev/null +++ b/ui/desktop/src/components/ActionPill.tsx @@ -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 = ({ + 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 ( +
+ + + {icon} + + {label} + + {onRemove && ( + + )} +
+ ); +}; + +export default ActionPill; diff --git a/ui/desktop/src/components/ActionPopover.tsx b/ui/desktop/src/components/ActionPopover.tsx new file mode 100644 index 0000000000..078665d711 --- /dev/null +++ b/ui/desktop/src/components/ActionPopover.tsx @@ -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(null); + const listRef = useRef(null); + const [allCommands, setAllCommands] = useState([]); + + // 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 = { + 'Zap': , + 'Code': , + 'FileText': , + 'Search': , + 'Play': , + 'Settings': , + 'Hash': , + }; + return iconMap[iconName || 'Zap'] || ; + }; + + // 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 = {}; + 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 ( +
+
+
+

+ {query ? 'Search Results' : 'Starred Commands'} +

+

+ {query ? `Commands matching "${query}"` : 'Your favorite slash commands'} +

+
+ +
+ {sortedActions.length > 0 ? ( + sortedActions.map((action, index) => ( +
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' + }`} + > +
+ {action.icon} +
+
+
+
+ {action.label} +
+
+
+ {action.description} +
+
+
+ )) + ) : ( +
+
+ {query + ? <> + : allCommands.length === 0 + ? 'No commands found' + : 'No starred commands found' + } +
+ {query && onCreateCommand ? ( + + ) : !query && allCommands.length === 0 ? ( + onCreateCommand ? ( + + ) : ( +
Create commands in Settings → Chat
+ ) + ) : ( +
Star commands to see them here when you type /
+ )} +
+ )} +
+
+
+ ); +}); + +ActionPopover.displayName = 'ActionPopover'; + +export default ActionPopover; diff --git a/ui/desktop/src/components/AddCustomCommandModal.tsx b/ui/desktop/src/components/AddCustomCommandModal.tsx new file mode 100644 index 0000000000..576bb16fd6 --- /dev/null +++ b/ui/desktop/src/components/AddCustomCommandModal.tsx @@ -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: }, + { name: 'Code', icon: }, + { name: 'FileText', icon: }, + { name: 'Search', icon: }, + { name: 'Play', icon: }, + { name: 'Settings', icon: }, + { name: 'Hash', icon: }, +]; + +export const AddCustomCommandModal: React.FC = ({ + isOpen, + onClose, + onSave, + editingCommand, +}) => { + const [formData, setFormData] = useState({ + name: '', + label: '', + description: '', + prompt: '', + icon: 'Zap', + }); + const [errors, setErrors] = useState>({}); + + // 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 = {}; + + 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 ( + { + if (!open) { + onClose(); + } + }} + > + + + + {editingCommand ? 'Edit Custom Command' : 'Add Custom Command'} + + + +
+
+ {/* Command Name */} +
+ +
+ + / + + 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`} + /> +
+ {errors.name && ( +

{errors.name}

+ )} +

+ This will be the command users type (e.g., /document) +

+
+ + {/* Display Label */} +
+ + 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 && ( +

{errors.label}

+ )} +

+ Friendly name shown in the command list +

+
+ + {/* Description */} +
+ + 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 && ( +

{errors.description}

+ )} +
+ + {/* Icon Selection */} +
+ +
+ {iconOptions.map((option) => ( + + ))} +
+
+ + + + {/* Prompt */} +
+ +