diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index 9f31f2567737..6ab9bb59dce8 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -149,7 +149,7 @@ mod tests { use mcp_core::{content::TextContent, Role}; use std::env; - #[warn(dead_code)] + #[allow(dead_code)] #[derive(Clone)] struct MockTestProvider { name: String, diff --git a/demo.json b/demo.json new file mode 100644 index 000000000000..bfbe0d8d653c --- /dev/null +++ b/demo.json @@ -0,0 +1,17 @@ +{ + "demo": { + "title": "Goose File Operations Demo", + "version": "1.0.0", + "features": [ + "File creation", + "File reading", + "Content modification", + "Multiple formats" + ], + "metadata": { + "created_by": "Goose AI Assistant", + "created_at": "2025-07-02T17:33:32Z", + "file_type": "demonstration" + } + } +} diff --git a/demo.txt b/demo.txt new file mode 100644 index 000000000000..a1e539c81009 --- /dev/null +++ b/demo.txt @@ -0,0 +1,17 @@ +Hello, World! + +This is a demonstration of file operations in Goose. + +Here are some key points: +- Files can be created and edited +- Content can be viewed and modified +- Multiple file formats are supported + +Current timestamp: 2025-07-02 17:33:32 + +--- UPDATE --- +This content was added after the initial file creation! +File modification operations include: +- String replacement +- Line insertion +- Content appending diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000000..6deca24a71b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "goose", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index e2de8305dac9..6aeb71cb1043 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1342,6 +1342,12 @@ "envs": { "$ref": "#/components/schemas/Envs" }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "name": { "type": "string", "description": "The name used to identify this extension" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 2f821de044b6..14a147a20fd0 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -132,6 +132,9 @@ export type ExtensionConfig = { description?: string | null; env_keys?: Array; envs?: Envs; + headers?: { + [key: string]: string; + }; /** * The name used to identify this extension */ diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index a785d5eeb4b1..528e4d4273ec 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -10,6 +10,7 @@ import { Message } from '../types/message'; import { useWhisper } from '../hooks/useWhisper'; import { WaveformVisualizer } from './WaveformVisualizer'; import { toastError } from '../toasts'; +import MentionPopover, { FileItemWithMatch } from './MentionPopover'; interface PastedImage { id: string; @@ -65,6 +66,20 @@ export default function ChatInput({ const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); const [pastedImages, setPastedImages] = useState([]); + const [mentionPopover, setMentionPopover] = useState<{ + isOpen: boolean; + position: { x: number; y: number }; + query: string; + mentionStart: number; + selectedIndex: number; + }>({ + isOpen: false, + position: { x: 0, y: 0 }, + query: '', + mentionStart: -1, + selectedIndex: 0, + }); + const mentionPopoverRef = useRef<{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void }>(null); // Whisper hook for voice dictation const { @@ -219,8 +234,48 @@ export default function ChatInput({ const handleChange = (evt: React.ChangeEvent) => { const val = evt.target.value; + const cursorPosition = evt.target.selectionStart; + setDisplayValue(val); // Update display immediately debouncedSetValue(val); // Debounce the actual state update + + // Check for @ mention + checkForMention(val, cursorPosition, evt.target); + }; + + const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => { + // Find the last @ before the cursor + const beforeCursor = text.slice(0, cursorPosition); + const lastAtIndex = beforeCursor.lastIndexOf('@'); + + if (lastAtIndex === -1) { + // No @ found, close mention popover + setMentionPopover(prev => ({ ...prev, isOpen: false })); + return; + } + + // Check if there's a space between @ and cursor (which would end the mention) + const afterAt = beforeCursor.slice(lastAtIndex + 1); + if (afterAt.includes(' ') || afterAt.includes('\n')) { + setMentionPopover(prev => ({ ...prev, isOpen: false })); + return; + } + + // Calculate position for the popover - position it above the chat input + const textAreaRect = textArea.getBoundingClientRect(); + + setMentionPopover(prev => ({ + ...prev, + isOpen: true, + position: { + x: textAreaRect.left, + y: textAreaRect.top, // Position at the top of the textarea + }, + query: afterAt, + mentionStart: lastAtIndex, + selectedIndex: 0, // Reset selection when query changes + // filteredFiles will be populated by the MentionPopover component + })); }; const handlePaste = async (evt: React.ClipboardEvent) => { @@ -425,6 +480,38 @@ export default function ChatInput({ }; const handleKeyDown = (evt: React.KeyboardEvent) => { + // If mention popover is open, handle arrow keys and enter + if (mentionPopover.isOpen && mentionPopoverRef.current) { + if (evt.key === 'ArrowDown') { + evt.preventDefault(); + const displayFiles = mentionPopoverRef.current.getDisplayFiles(); + const maxIndex = Math.max(0, displayFiles.length - 1); + setMentionPopover(prev => ({ + ...prev, + selectedIndex: Math.min(prev.selectedIndex + 1, maxIndex) + })); + return; + } + if (evt.key === 'ArrowUp') { + evt.preventDefault(); + setMentionPopover(prev => ({ + ...prev, + selectedIndex: Math.max(prev.selectedIndex - 1, 0) + })); + return; + } + if (evt.key === 'Enter') { + evt.preventDefault(); + mentionPopoverRef.current.selectFile(mentionPopover.selectedIndex); + return; + } + if (evt.key === 'Escape') { + evt.preventDefault(); + setMentionPopover(prev => ({ ...prev, isOpen: false })); + return; + } + } + // Handle history navigation first handleHistoryNavigation(evt); @@ -474,223 +561,256 @@ export default function ChatInput({ } }; + const handleMentionFileSelect = (filePath: string) => { + // Replace the @ mention with the file path + const beforeMention = displayValue.slice(0, mentionPopover.mentionStart); + const afterMention = displayValue.slice(mentionPopover.mentionStart + 1 + mentionPopover.query.length); + const newValue = `${beforeMention}${filePath}${afterMention}`; + + setDisplayValue(newValue); + setValue(newValue); + setMentionPopover(prev => ({ ...prev, isOpen: false })); + textAreaRef.current?.focus(); + + // Set cursor position after the inserted file path + setTimeout(() => { + if (textAreaRef.current) { + const newCursorPosition = beforeMention.length + filePath.length; + textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); + } + }, 0); + }; + const hasSubmittableContent = displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading); const isAnyImageLoading = pastedImages.some((img) => img.isLoading); return ( -
-
-
-