import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Send,
Maximize2,
Minimize2,
ChevronUp,
Sparkles,
Zap,
Square,
Brain
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Popover } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { FilePicker } from "./FilePicker";
import { SlashCommandPicker } from "./SlashCommandPicker";
import { ImagePreview } from "./ImagePreview";
import { type FileEntry, type SlashCommand } from "@/lib/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useTranslation } from "react-i18next";
interface FloatingPromptInputProps {
/**
* Callback when prompt is sent
*/
onSend: (prompt: string, model: "sonnet" | "opus" | "opus-plan") => void;
/**
* Whether the input is loading
*/
isLoading?: boolean;
/**
* Whether the input is disabled
*/
disabled?: boolean;
/**
* Default model to select
*/
defaultModel?: "sonnet" | "opus" | "opus-plan";
/**
* Project path for file picker
*/
projectPath?: string;
/**
* Optional className for styling
*/
className?: string;
/**
* Callback when cancel is clicked (only during loading)
*/
onCancel?: () => void;
}
export interface FloatingPromptInputRef {
addImage: (imagePath: string) => void;
}
/**
* Thinking mode type definition
*/
type ThinkingMode = "auto" | "think" | "think_hard" | "think_harder" | "ultrathink";
/**
* Thinking mode configuration
*/
type ThinkingModeConfig = {
id: ThinkingMode;
name: string;
description: string;
level: number; // 0-4 for visual indicator
phrase?: string; // The phrase to append
};
// Thinking modes configuration will be defined inside the component to use translations
/**
* ThinkingModeIndicator component - Shows visual indicator bars for thinking level
*/
const ThinkingModeIndicator: React.FC<{ level: number }> = ({ level }) => {
return (
{[1, 2, 3, 4].map((i) => (
))}
);
};
type Model = {
id: "sonnet" | "opus" | "opus-plan";
name: string;
description: string;
icon: React.ReactNode;
};
/**
* FloatingPromptInput component - Fixed position prompt input with model picker
*
* @example
* const promptRef = useRef(null);
* console.log('Send:', prompt, model)}
* isLoading={false}
* />
*/
const FloatingPromptInputInner = (
{
onSend,
isLoading = false,
disabled = false,
defaultModel = "sonnet",
projectPath,
className,
onCancel,
}: FloatingPromptInputProps,
ref: React.Ref,
) => {
const { t } = useTranslation();
// Define MODELS inside component to access translations
const MODELS: Model[] = [
{
id: "sonnet",
name: t('agents.sonnetName'),
description: t('agents.sonnetDescription'),
icon:
},
{
id: "opus",
name: t('agents.opusName'),
description: t('agents.opusDescription'),
icon:
},
{
id: "opus-plan",
name: t('agents.opusPlanName'),
description: t('agents.opusPlanDescription'),
icon:
}
];
// Define THINKING_MODES inside component to access translations
const THINKING_MODES: ThinkingModeConfig[] = [
{
id: "auto",
name: t('messages.auto'),
description: t('messages.letClaudeDecide'),
level: 0
},
{
id: "think",
name: t('messages.think'),
description: t('messages.basicReasoning'),
level: 1,
phrase: "think"
},
{
id: "think_hard",
name: t('messages.thinkHard'),
description: t('messages.deeperAnalysis'),
level: 2,
phrase: "think hard"
},
{
id: "think_harder",
name: t('messages.thinkHarder'),
description: t('messages.extensiveReasoning'),
level: 3,
phrase: "think harder"
},
{
id: "ultrathink",
name: t('messages.ultrathink'),
description: t('messages.maximumAnalysis'),
level: 4,
phrase: "ultrathink"
}
];
const [prompt, setPrompt] = useState("");
const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus" | "opus-plan">(defaultModel);
const [selectedThinkingMode, setSelectedThinkingMode] = useState("auto");
const [isExpanded, setIsExpanded] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [thinkingModePickerOpen, setThinkingModePickerOpen] = useState(false);
const [showFilePicker, setShowFilePicker] = useState(false);
const [filePickerQuery, setFilePickerQuery] = useState("");
const [showSlashCommandPicker, setShowSlashCommandPicker] = useState(false);
const [slashCommandQuery, setSlashCommandQuery] = useState("");
const [cursorPosition, setCursorPosition] = useState(0);
const [embeddedImages, setEmbeddedImages] = useState([]);
const [dragActive, setDragActive] = useState(false);
const textareaRef = useRef(null);
const expandedTextareaRef = useRef(null);
const unlistenDragDropRef = useRef<(() => void) | null>(null);
// Expose a method to add images programmatically
React.useImperativeHandle(
ref,
() => ({
addImage: (imagePath: string) => {
setPrompt(currentPrompt => {
const existingPaths = extractImagePaths(currentPrompt);
if (existingPaths.includes(imagePath)) {
return currentPrompt; // Image already added
}
// Wrap path in quotes if it contains spaces
const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`;
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
// Focus the textarea
setTimeout(() => {
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
target?.focus();
target?.setSelectionRange(newPrompt.length, newPrompt.length);
}, 0);
return newPrompt;
});
}
}),
[isExpanded]
);
// Helper function to check if a file is an image
const isImageFile = (path: string): boolean => {
// Check if it's a data URL
if (path.startsWith('data:image/')) {
return true;
}
// Otherwise check file extension
const ext = path.split('.').pop()?.toLowerCase();
return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'].includes(ext || '');
};
// Extract image paths from prompt text
const extractImagePaths = (text: string): string[] => {
console.log('[extractImagePaths] Input text length:', text.length);
// Updated regex to handle both quoted and unquoted paths
// Pattern 1: @"path with spaces or data URLs" - quoted paths
// Pattern 2: @path - unquoted paths (continues until @ or end)
const quotedRegex = /@"([^"]+)"/g;
const unquotedRegex = /@([^@\n\s]+)/g;
const pathsSet = new Set(); // Use Set to ensure uniqueness
// First, extract quoted paths (including data URLs)
let matches = Array.from(text.matchAll(quotedRegex));
console.log('[extractImagePaths] Quoted matches:', matches.length);
for (const match of matches) {
const path = match[1]; // No need to trim, quotes preserve exact path
console.log('[extractImagePaths] Processing quoted path:', path.startsWith('data:') ? 'data URL' : path);
// For data URLs, use as-is; for file paths, convert to absolute
const fullPath = path.startsWith('data:')
? path
: (path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path));
if (isImageFile(fullPath)) {
pathsSet.add(fullPath);
}
}
// Remove quoted mentions from text to avoid double-matching
let textWithoutQuoted = text.replace(quotedRegex, '');
// Then extract unquoted paths (typically file paths)
matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex));
console.log('[extractImagePaths] Unquoted matches:', matches.length);
for (const match of matches) {
const path = match[1].trim();
// Skip if it looks like a data URL fragment (shouldn't happen with proper quoting)
if (path.includes('data:')) continue;
console.log('[extractImagePaths] Processing unquoted path:', path);
// Convert relative path to absolute if needed
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
if (isImageFile(fullPath)) {
pathsSet.add(fullPath);
}
}
const uniquePaths = Array.from(pathsSet);
console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths.length);
return uniquePaths;
};
// Update embedded images when prompt changes
useEffect(() => {
console.log('[useEffect] Prompt changed:', prompt);
const imagePaths = extractImagePaths(prompt);
console.log('[useEffect] Setting embeddedImages to:', imagePaths);
setEmbeddedImages(imagePaths);
}, [prompt, projectPath]);
// Set up Tauri drag-drop event listener
useEffect(() => {
// This effect runs only once on component mount to set up the listener.
let lastDropTime = 0;
const setupListener = async () => {
try {
// If a listener from a previous mount/render is still around, clean it up.
if (unlistenDragDropRef.current) {
unlistenDragDropRef.current();
}
const webview = getCurrentWebviewWindow();
unlistenDragDropRef.current = await webview.onDragDropEvent((event) => {
if (event.payload.type === 'enter' || event.payload.type === 'over') {
setDragActive(true);
} else if (event.payload.type === 'leave') {
setDragActive(false);
} else if (event.payload.type === 'drop' && event.payload.paths) {
setDragActive(false);
const currentTime = Date.now();
if (currentTime - lastDropTime < 200) {
// This debounce is crucial to handle the storm of drop events
// that Tauri/OS can fire for a single user action.
return;
}
lastDropTime = currentTime;
const droppedPaths = event.payload.paths as string[];
const imagePaths = droppedPaths.filter(isImageFile);
if (imagePaths.length > 0) {
setPrompt(currentPrompt => {
const existingPaths = extractImagePaths(currentPrompt);
const newPaths = imagePaths.filter(p => !existingPaths.includes(p));
if (newPaths.length === 0) {
return currentPrompt; // All dropped images are already in the prompt
}
// Wrap paths with spaces in quotes for clarity
const mentionsToAdd = newPaths.map(p => {
// If path contains spaces, wrap in quotes
if (p.includes(' ')) {
return `@"${p}"`;
}
return `@${p}`;
}).join(' ');
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' ';
setTimeout(() => {
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
target?.focus();
target?.setSelectionRange(newPrompt.length, newPrompt.length);
}, 0);
return newPrompt;
});
}
}
});
} catch (error) {
console.error('Failed to set up Tauri drag-drop listener:', error);
}
};
setupListener();
return () => {
// On unmount, ensure we clean up the listener.
if (unlistenDragDropRef.current) {
unlistenDragDropRef.current();
unlistenDragDropRef.current = null;
}
};
}, []); // Empty dependency array ensures this runs only on mount/unmount.
useEffect(() => {
// Focus the appropriate textarea when expanded state changes
if (isExpanded && expandedTextareaRef.current) {
expandedTextareaRef.current.focus();
} else if (!isExpanded && textareaRef.current) {
textareaRef.current.focus();
}
}, [isExpanded]);
const handleSend = () => {
if (prompt.trim() && !disabled) {
let finalPrompt = prompt.trim();
// Append thinking phrase if not auto mode
const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode);
if (thinkingMode && thinkingMode.phrase) {
finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`;
}
onSend(finalPrompt, selectedModel);
setPrompt("");
setEmbeddedImages([]);
}
};
const handleTextChange = (e: React.ChangeEvent) => {
const newValue = e.target.value;
const newCursorPosition = e.target.selectionStart || 0;
// Check if / was just typed at the beginning of input or after whitespace
if (newValue.length > prompt.length && newValue[newCursorPosition - 1] === '/') {
// Check if it's at the start or after whitespace
const isStartOfCommand = newCursorPosition === 1 ||
(newCursorPosition > 1 && /\s/.test(newValue[newCursorPosition - 2]));
if (isStartOfCommand) {
console.log('[FloatingPromptInput] / detected for slash command');
setShowSlashCommandPicker(true);
setSlashCommandQuery("");
setCursorPosition(newCursorPosition);
}
}
// Check if @ was just typed
if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') {
console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath);
setShowFilePicker(true);
setFilePickerQuery("");
setCursorPosition(newCursorPosition);
}
// Check if we're typing after / (for slash command search)
if (showSlashCommandPicker && newCursorPosition >= cursorPosition) {
// Find the / position before cursor
let slashPosition = -1;
for (let i = newCursorPosition - 1; i >= 0; i--) {
if (newValue[i] === '/') {
slashPosition = i;
break;
}
// Stop if we hit whitespace (new word)
if (newValue[i] === ' ' || newValue[i] === '\n') {
break;
}
}
if (slashPosition !== -1) {
const query = newValue.substring(slashPosition + 1, newCursorPosition);
setSlashCommandQuery(query);
} else {
// / was removed or cursor moved away
setShowSlashCommandPicker(false);
setSlashCommandQuery("");
}
}
// Check if we're typing after @ (for search query)
if (showFilePicker && newCursorPosition >= cursorPosition) {
// Find the @ position before cursor
let atPosition = -1;
for (let i = newCursorPosition - 1; i >= 0; i--) {
if (newValue[i] === '@') {
atPosition = i;
break;
}
// Stop if we hit whitespace (new word)
if (newValue[i] === ' ' || newValue[i] === '\n') {
break;
}
}
if (atPosition !== -1) {
const query = newValue.substring(atPosition + 1, newCursorPosition);
setFilePickerQuery(query);
} else {
// @ was removed or cursor moved away
setShowFilePicker(false);
setFilePickerQuery("");
}
}
setPrompt(newValue);
setCursorPosition(newCursorPosition);
};
const handleFileSelect = (entry: FileEntry) => {
if (textareaRef.current) {
// Find the @ position before cursor
let atPosition = -1;
for (let i = cursorPosition - 1; i >= 0; i--) {
if (prompt[i] === '@') {
atPosition = i;
break;
}
// Stop if we hit whitespace (new word)
if (prompt[i] === ' ' || prompt[i] === '\n') {
break;
}
}
if (atPosition === -1) {
// @ not found, this shouldn't happen but handle gracefully
console.error('[FloatingPromptInput] @ position not found');
return;
}
// Replace the @ and partial query with the selected path (file or directory)
const textarea = textareaRef.current;
const beforeAt = prompt.substring(0, atPosition);
const afterCursor = prompt.substring(cursorPosition);
const relativePath = entry.path.startsWith(projectPath || '')
? entry.path.slice((projectPath || '').length + 1)
: entry.path;
const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`;
setPrompt(newPrompt);
setShowFilePicker(false);
setFilePickerQuery("");
// Focus back on textarea and set cursor position after the inserted path
setTimeout(() => {
textarea.focus();
const newCursorPos = beforeAt.length + relativePath.length + 2; // +2 for @ and space
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
}
};
const handleFilePickerClose = () => {
setShowFilePicker(false);
setFilePickerQuery("");
// Return focus to textarea
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
};
const handleSlashCommandSelect = (command: SlashCommand) => {
const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current;
if (!textarea) return;
// Find the / position before cursor
let slashPosition = -1;
for (let i = cursorPosition - 1; i >= 0; i--) {
if (prompt[i] === '/') {
slashPosition = i;
break;
}
// Stop if we hit whitespace (new word)
if (prompt[i] === ' ' || prompt[i] === '\n') {
break;
}
}
if (slashPosition === -1) {
console.error('[FloatingPromptInput] / position not found');
return;
}
// Simply insert the command syntax
const beforeSlash = prompt.substring(0, slashPosition);
const afterCursor = prompt.substring(cursorPosition);
if (command.accepts_arguments) {
// Insert command with placeholder for arguments
const newPrompt = `${beforeSlash}${command.full_command} `;
setPrompt(newPrompt);
setShowSlashCommandPicker(false);
setSlashCommandQuery("");
// Focus and position cursor after the command
setTimeout(() => {
textarea.focus();
const newCursorPos = beforeSlash.length + command.full_command.length + 1;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
} else {
// Insert command and close picker
const newPrompt = `${beforeSlash}${command.full_command} ${afterCursor}`;
setPrompt(newPrompt);
setShowSlashCommandPicker(false);
setSlashCommandQuery("");
// Focus and position cursor after the command
setTimeout(() => {
textarea.focus();
const newCursorPos = beforeSlash.length + command.full_command.length + 1;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
}
};
const handleSlashCommandPickerClose = () => {
setShowSlashCommandPicker(false);
setSlashCommandQuery("");
// Return focus to textarea
setTimeout(() => {
const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current;
textarea?.focus();
}, 0);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showFilePicker && e.key === 'Escape') {
e.preventDefault();
setShowFilePicker(false);
setFilePickerQuery("");
return;
}
if (showSlashCommandPicker && e.key === 'Escape') {
e.preventDefault();
setShowSlashCommandPicker(false);
setSlashCommandQuery("");
return;
}
// 处理发送快捷键
if (e.key === "Enter") {
if (isExpanded) {
// 展开模式:Ctrl+Enter发送,Enter换行
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation(); // 防止事件冒泡到窗口级别
handleSend();
}
// 普通Enter键在展开模式下允许换行,不需要处理
} else {
// 收起模式:Enter发送,Shift+Enter换行
if (!e.shiftKey && !showFilePicker && !showSlashCommandPicker) {
e.preventDefault();
e.stopPropagation(); // 防止事件冒泡到窗口级别
handleSend();
}
}
}
};
const handlePaste = async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
// Get the image blob
const blob = item.getAsFile();
if (!blob) continue;
try {
// Convert blob to base64
const reader = new FileReader();
reader.onload = () => {
const base64Data = reader.result as string;
// Add the base64 data URL directly to the prompt
setPrompt(currentPrompt => {
// Use the data URL directly as the image reference
const mention = `@"${base64Data}"`;
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
// Focus the textarea and move cursor to end
setTimeout(() => {
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
target?.focus();
target?.setSelectionRange(newPrompt.length, newPrompt.length);
}, 0);
return newPrompt;
});
};
reader.readAsDataURL(blob);
} catch (error) {
console.error('Failed to paste image:', error);
}
}
}
};
// Browser drag and drop handlers - just prevent default behavior
// Actual file handling is done via Tauri's window-level drag-drop events
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Visual feedback is handled by Tauri events
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// File processing is handled by Tauri's onDragDropEvent
};
const handleRemoveImage = (index: number) => {
// Remove the corresponding @mention from the prompt
const imagePath = embeddedImages[index];
// For data URLs, we need to handle them specially since they're always quoted
if (imagePath.startsWith('data:')) {
// Simply remove the exact quoted data URL
const quotedPath = `@"${imagePath}"`;
const newPrompt = prompt.replace(quotedPath, '').trim();
setPrompt(newPrompt);
return;
}
// For file paths, use the original logic
const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedRelativePath = imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Create patterns for both quoted and unquoted mentions
const patterns = [
// Quoted full path
new RegExp(`@"${escapedPath}"\\s?`, 'g'),
// Unquoted full path
new RegExp(`@${escapedPath}\\s?`, 'g'),
// Quoted relative path
new RegExp(`@"${escapedRelativePath}"\\s?`, 'g'),
// Unquoted relative path
new RegExp(`@${escapedRelativePath}\\s?`, 'g')
];
let newPrompt = prompt;
for (const pattern of patterns) {
newPrompt = newPrompt.replace(pattern, '');
}
setPrompt(newPrompt.trim());
};
const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0];
return (
<>
{/* Expanded Modal */}
{isExpanded && (
setIsExpanded(false)}
>
e.stopPropagation()}
>
{t('input.composeYourPrompt')}
{/* Image previews in expanded mode */}
{embeddedImages.length > 0 && (
)}
{t('app.model')}:
{t('messages.thinking')}:
{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name || t('messages.auto')}
{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}
}
content={
{THINKING_MODES.map((mode) => (
))}
}
open={thinkingModePickerOpen}
onOpenChange={setThinkingModePickerOpen}
align="start"
side="top"
/>
)}
{/* Fixed Position Input Bar */}
{/* Image previews */}
{embeddedImages.length > 0 && (
)}
{/* Model Picker */}
{selectedModelData.icon}
{selectedModelData.name}
}
content={
{MODELS.map((model) => (
))}
}
open={modelPickerOpen}
onOpenChange={setModelPickerOpen}
align="start"
side="top"
/>
{/* Thinking Mode Picker */}
{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name || t('messages.auto')}
{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}
}
content={
{THINKING_MODES.map((mode) => (
))}
}
open={thinkingModePickerOpen}
onOpenChange={setThinkingModePickerOpen}
align="start"
side="top"
/>
{/* Prompt Input */}
{/* File Picker */}
{showFilePicker && projectPath && projectPath.trim() && (
)}
{/* Slash Command Picker */}
{showSlashCommandPicker && (
)}
{/* Send/Stop Button */}
>
);
};
export const FloatingPromptInput = React.forwardRef<
FloatingPromptInputRef,
FloatingPromptInputProps
>(FloatingPromptInputInner);
FloatingPromptInput.displayName = 'FloatingPromptInput';