Files
claudia/src/components/FloatingPromptInput.tsx
YoVinchen 96eb05856e
Some checks are pending
Build Linux / Build Linux x86_64 (push) Waiting to run
Build Test / Build Test (${{ matrix.platform.name }}) (map[name:Linux os:ubuntu-latest rust-target:x86_64-unknown-linux-gnu]) (push) Waiting to run
Build Test / Build Test (${{ matrix.platform.name }}) (map[name:Windows os:windows-latest rust-target:x86_64-pc-windows-msvc]) (push) Waiting to run
Build Test / Build Test (${{ matrix.platform.name }}) (map[name:macOS os:macos-latest rust-target:x86_64-apple-darwin]) (push) Waiting to run
Build Test / Build Test Summary (push) Blocked by required conditions
增加 opusplan 模式
2025-08-14 00:21:11 +08:00

1107 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={cn(
"w-1 h-3 rounded-full transition-colors",
i <= level ? "bg-blue-500" : "bg-muted"
)}
/>
))}
</div>
);
};
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<FloatingPromptInputRef>(null);
* <FloatingPromptInput
* ref={promptRef}
* onSend={(prompt, model) => console.log('Send:', prompt, model)}
* isLoading={false}
* />
*/
const FloatingPromptInputInner = (
{
onSend,
isLoading = false,
disabled = false,
defaultModel = "sonnet",
projectPath,
className,
onCancel,
}: FloatingPromptInputProps,
ref: React.Ref<FloatingPromptInputRef>,
) => {
const { t } = useTranslation();
// Define MODELS inside component to access translations
const MODELS: Model[] = [
{
id: "sonnet",
name: t('agents.sonnetName'),
description: t('agents.sonnetDescription'),
icon: <Zap className="h-4 w-4" />
},
{
id: "opus",
name: t('agents.opusName'),
description: t('agents.opusDescription'),
icon: <Sparkles className="h-4 w-4" />
},
{
id: "opus-plan",
name: t('agents.opusPlanName'),
description: t('agents.opusPlanDescription'),
icon: <Brain className="h-4 w-4" />
}
];
// 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<ThinkingMode>("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<string[]>([]);
const [dragActive, setDragActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const expandedTextareaRef = useRef<HTMLTextAreaElement>(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<string>(); // 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<HTMLTextAreaElement>) => {
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 */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
onClick={() => setIsExpanded(false)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-2xl p-4 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t('input.composeYourPrompt')}</h3>
<Button
variant="ghost"
size="icon"
onClick={() => setIsExpanded(false)}
className="h-8 w-8"
>
<Minimize2 className="h-4 w-4" />
</Button>
</div>
{/* Image previews in expanded mode */}
{embeddedImages.length > 0 && (
<ImagePreview
images={embeddedImages}
onRemove={handleRemoveImage}
className="border-t border-border pt-2"
/>
)}
<Textarea
ref={expandedTextareaRef}
value={prompt}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={t('messages.typeYourPromptHere')}
className="min-h-[200px] resize-none"
disabled={disabled}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t('app.model')}:</span>
<Button
variant="outline"
size="sm"
onClick={() => setModelPickerOpen(!modelPickerOpen)}
className="gap-2"
>
{selectedModelData.icon}
{selectedModelData.name}
</Button>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t('messages.thinking')}:</span>
<Popover
trigger={
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setThinkingModePickerOpen(!thinkingModePickerOpen)}
className="gap-2"
>
<Brain className="h-4 w-4" />
<ThinkingModeIndicator
level={THINKING_MODES.find(m => m.id === selectedThinkingMode)?.level || 0}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name || t('messages.auto')}</p>
<p className="text-xs text-muted-foreground">{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
content={
<div className="w-[280px] p-1">
{THINKING_MODES.map((mode) => (
<button
key={mode.id}
onClick={() => {
setSelectedThinkingMode(mode.id);
setThinkingModePickerOpen(false);
}}
className={cn(
"w-full flex items-start gap-3 p-3 rounded-md transition-colors text-left",
"hover:bg-accent",
selectedThinkingMode === mode.id && "bg-accent"
)}
>
<Brain className="h-4 w-4 mt-0.5" />
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">
{mode.name}
</div>
<div className="text-xs text-muted-foreground">
{mode.description}
</div>
</div>
<ThinkingModeIndicator level={mode.level} />
</button>
))}
</div>
}
open={thinkingModePickerOpen}
onOpenChange={setThinkingModePickerOpen}
align="start"
side="top"
/>
</div>
</div>
<Button
onClick={handleSend}
disabled={!prompt.trim() || disabled}
size="default"
className="min-w-[60px]"
>
{isLoading ? (
<div className="rotating-symbol text-primary-foreground" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Fixed Position Input Bar */}
<div
className={cn(
"w-full bg-background/98 backdrop-blur-sm border-t border-border/40",
dragActive && "ring-2 ring-primary ring-offset-2",
className
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<div className="w-full">
{/* Image previews */}
{embeddedImages.length > 0 && (
<ImagePreview
images={embeddedImages}
onRemove={handleRemoveImage}
className="border-b border-border"
/>
)}
<div className="p-2">
<div className="flex items-center gap-2">
{/* Model Picker */}
<Popover
trigger={
<Button
variant="outline"
size="default"
disabled={disabled}
className="gap-2 min-w-[140px] h-8 justify-start"
>
{selectedModelData.icon}
<span className="flex-1 text-left">{selectedModelData.name}</span>
<ChevronUp className="h-4 w-4 opacity-50" />
</Button>
}
content={
<div className="w-[300px] p-1">
{MODELS.map((model) => (
<button
key={model.id}
onClick={() => {
setSelectedModel(model.id);
setModelPickerOpen(false);
}}
className={cn(
"w-full flex items-start gap-3 p-3 rounded-md transition-colors text-left",
"hover:bg-accent",
selectedModel === model.id && "bg-accent"
)}
>
<div className="mt-0.5">{model.icon}</div>
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">{model.name}</div>
<div className="text-xs text-muted-foreground">
{model.description}
</div>
</div>
</button>
))}
</div>
}
open={modelPickerOpen}
onOpenChange={setModelPickerOpen}
align="start"
side="top"
/>
{/* Thinking Mode Picker */}
<Popover
trigger={
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="default"
disabled={disabled}
className="gap-2 h-8"
>
<Brain className="h-4 w-4" />
<ThinkingModeIndicator
level={THINKING_MODES.find(m => m.id === selectedThinkingMode)?.level || 0}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name || t('messages.auto')}</p>
<p className="text-xs text-muted-foreground">{THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
content={
<div className="w-[280px] p-1">
{THINKING_MODES.map((mode) => (
<button
key={mode.id}
onClick={() => {
setSelectedThinkingMode(mode.id);
setThinkingModePickerOpen(false);
}}
className={cn(
"w-full flex items-start gap-3 p-3 rounded-md transition-colors text-left",
"hover:bg-accent",
selectedThinkingMode === mode.id && "bg-accent"
)}
>
<Brain className="h-4 w-4 mt-0.5" />
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">
{mode.name}
</div>
<div className="text-xs text-muted-foreground">
{mode.description}
</div>
</div>
<ThinkingModeIndicator level={mode.level} />
</button>
))}
</div>
}
open={thinkingModePickerOpen}
onOpenChange={setThinkingModePickerOpen}
align="start"
side="top"
/>
{/* Prompt Input */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={prompt}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dragActive ? t('messages.dropImagesHere') : t('messages.askClaudeAnything')}
disabled={disabled}
className={cn(
"min-h-[32px] max-h-[100px] resize-none pr-10 py-1",
dragActive && "border-primary"
)}
rows={1}
/>
<Button
variant="ghost"
size="icon"
onClick={() => setIsExpanded(true)}
disabled={disabled}
className="absolute right-1 bottom-0 h-6 w-6"
>
<Maximize2 className="h-4 w-4" />
</Button>
{/* File Picker */}
<AnimatePresence>
{showFilePicker && projectPath && projectPath.trim() && (
<FilePicker
basePath={projectPath.trim()}
onSelect={handleFileSelect}
onClose={handleFilePickerClose}
initialQuery={filePickerQuery}
/>
)}
</AnimatePresence>
{/* Slash Command Picker */}
<AnimatePresence>
{showSlashCommandPicker && (
<SlashCommandPicker
projectPath={projectPath}
onSelect={handleSlashCommandSelect}
onClose={handleSlashCommandPickerClose}
initialQuery={slashCommandQuery}
/>
)}
</AnimatePresence>
</div>
{/* Send/Stop Button */}
<Button
onClick={isLoading ? onCancel : handleSend}
disabled={isLoading ? false : (!prompt.trim() || disabled)}
variant={isLoading ? "destructive" : "default"}
size="default"
className="min-w-[56px] h-8"
>
{isLoading ? (
<>
<Square className="h-4 w-4 mr-1" />
Stop
</>
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
</>
);
};
export const FloatingPromptInput = React.forwardRef<
FloatingPromptInputRef,
FloatingPromptInputProps
>(FloatingPromptInputInner);
FloatingPromptInput.displayName = 'FloatingPromptInput';