diff --git a/src/components/chat/CliToolsPopover.tsx b/src/components/chat/CliToolsPopover.tsx new file mode 100644 index 0000000..fba4fd3 --- /dev/null +++ b/src/components/chat/CliToolsPopover.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useCallback } from 'react'; +import { Terminal } from '@phosphor-icons/react'; +import { cn } from '@/lib/utils'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; + +export interface CliToolItem { + id: string; + name: string; + version: string | null; + summary: string; +} + +interface CliToolsPopoverProps { + popoverRef: React.RefObject; + cliTools: CliToolItem[]; + cliFilter: string; + selectedIndex: number; + cliSearchRef: React.RefObject; + onSetCliFilter: (filter: string) => void; + onSetSelectedIndex: (index: number) => void; + onCliSelect: (tool: CliToolItem) => void; + onClosePopover: () => void; + onFocusTextarea: () => void; +} + +export function CliToolsPopover({ + popoverRef, + cliTools, + cliFilter, + selectedIndex, + cliSearchRef, + onSetCliFilter, + onSetSelectedIndex, + onCliSelect, + onClosePopover, + onFocusTextarea, +}: CliToolsPopoverProps) { + const { t } = useTranslation(); + + const q = cliFilter.toLowerCase(); + const filtered = cliTools.filter(tool => + tool.name.toLowerCase().includes(q) || tool.summary.toLowerCase().includes(q) + ); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + onSetSelectedIndex(Math.min(selectedIndex + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + onSetSelectedIndex(Math.max(selectedIndex - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filtered[selectedIndex]) onCliSelect(filtered[selectedIndex]); + } else if (e.key === 'Escape') { + e.preventDefault(); + onClosePopover(); + onFocusTextarea(); + } + }, [selectedIndex, filtered, onSetSelectedIndex, onCliSelect, onClosePopover, onFocusTextarea]); + + return ( +
+
+ { onSetCliFilter(e.target.value); onSetSelectedIndex(0); }} + onKeyDown={handleSearchKeyDown} + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + /> +
+
+ {filtered.length > 0 ? ( + filtered.map((tool, idx) => ( + + )) + ) : ( +
+

{t('cliTools.noToolsDetected' as TranslationKey)}

+ +
+ )} +
+ {/* Footer: manage CLI tools */} +
+ +
+
+ ); +} diff --git a/src/components/chat/EffortSelectorDropdown.tsx b/src/components/chat/EffortSelectorDropdown.tsx new file mode 100644 index 0000000..c336305 --- /dev/null +++ b/src/components/chat/EffortSelectorDropdown.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; +import { CaretDown } from '@phosphor-icons/react'; +import { cn } from '@/lib/utils'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; +import { PromptInputButton } from '@/components/ai-elements/prompt-input'; + +interface EffortSelectorDropdownProps { + selectedEffort: string; + onEffortChange: (effort: string) => void; + supportedEffortLevels?: string[]; +} + +export function EffortSelectorDropdown({ + selectedEffort, + onEffortChange, + supportedEffortLevels, +}: EffortSelectorDropdownProps) { + const { t } = useTranslation(); + const effortMenuRef = useRef(null); + const [effortMenuOpen, setEffortMenuOpen] = useState(false); + + const levels = supportedEffortLevels || ['low', 'medium', 'high', 'max']; + + // Close effort menu on outside click + useEffect(() => { + if (!effortMenuOpen) return; + const handler = (e: MouseEvent) => { + if (effortMenuRef.current && !effortMenuRef.current.contains(e.target as Node)) { + setEffortMenuOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [effortMenuOpen]); + + return ( +
+ setEffortMenuOpen((prev) => !prev)} + > + {t(`messageInput.effort.${selectedEffort}` as TranslationKey)} + + + + {effortMenuOpen && ( +
+
+ {t('messageInput.effort.label' as TranslationKey)} +
+
+ {levels.map((level) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index ae2c1e0..3b0e799 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -4,7 +4,6 @@ import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type Form import { At, Question, - CaretDown, ArrowUp, Terminal, Plus, @@ -19,6 +18,7 @@ import { GlobeSimple, Stop, Lightning, + CaretDown, Gear, } from "@phosphor-icons/react"; import type { Icon } from "@phosphor-icons/react"; @@ -38,6 +38,12 @@ import type { ChatStatus } from 'ai'; import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types'; import { nanoid } from 'nanoid'; import { SlashCommandButton } from './SlashCommandButton'; +import { SlashCommandPopover } from './SlashCommandPopover'; +import type { PopoverItem, PopoverMode } from './SlashCommandPopover'; +import { CliToolsPopover } from './CliToolsPopover'; +import type { CliToolItem } from './CliToolsPopover'; +import { ModelSelectorDropdown } from './ModelSelectorDropdown'; +import { EffortSelectorDropdown } from './EffortSelectorDropdown'; import { Tooltip, TooltipContent, @@ -112,19 +118,6 @@ interface MessageInputProps { sdkInitMeta?: { tools?: unknown; slash_commands?: unknown; skills?: unknown } | null; } -interface PopoverItem { - label: string; - value: string; - description?: string; - descriptionKey?: TranslationKey; - builtIn?: boolean; - immediate?: boolean; - installedSource?: "agents" | "claude"; - source?: "global" | "project" | "plugin" | "installed" | "sdk"; - kind?: SkillKind; - icon?: Icon; -} - interface CommandBadge { command: string; label: string; @@ -138,15 +131,6 @@ interface CliBadge { name: string; } -interface CliToolItem { - id: string; - name: string; - version: string | null; - summary: string; -} - -type PopoverMode = 'file' | 'skill' | 'cli' | null; - // Expansion prompts for CLI-only commands (not natively supported by SDK). // SDK-native commands (/compact, /init, /review) are sent as-is — the SDK handles them directly. const COMMAND_PROMPTS: Record = { @@ -351,15 +335,11 @@ export function MessageInput({ const textareaRef = useRef(null); const popoverRef = useRef(null); const searchInputRef = useRef(null); - const modelMenuRef = useRef(null); - const [popoverMode, setPopoverMode] = useState(null); const [popoverItems, setPopoverItems] = useState([]); const [popoverFilter, setPopoverFilter] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [triggerPos, setTriggerPos] = useState(null); - const [modelMenuOpen, setModelMenuOpen] = useState(false); - const [modelSearch, setModelSearch] = useState(''); const [inputValue, setInputValue] = useState(''); const [badge, setBadge] = useState(null); const [cliBadge, setCliBadge] = useState(null); @@ -372,6 +352,11 @@ export function MessageInput({ const [aiSearchLoading, setAiSearchLoading] = useState(false); const aiSearchAbortRef = useRef(null); const aiSearchTimerRef = useRef | null>(null); + const modelMenuRef = useRef(null); + const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [modelSearch, setModelSearch] = useState(''); + const effortMenuRef = useRef(null); + const [effortMenuOpen, setEffortMenuOpen] = useState(false); // Assistant trigger on first focus const assistantTriggerFired = useRef(false); @@ -1128,19 +1113,6 @@ export function MessageInput({ return () => document.removeEventListener('mousedown', handler); }, [popoverMode, closePopover]); - // Click outside to close model menu - useEffect(() => { - if (!modelMenuOpen) return; - const handler = (e: MouseEvent) => { - if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) { - setModelMenuOpen(false); - setModelSearch(''); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [modelMenuOpen]); - const currentModelValue = modelName || 'sonnet'; const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0]; @@ -1154,21 +1126,6 @@ export function MessageInput({ setLocalEffort(v); onEffortChange?.(v); }, [onEffortChange]); - const [effortMenuOpen, setEffortMenuOpen] = useState(false); - const effortMenuRef = useRef(null); - - // Close effort menu on outside click - useEffect(() => { - if (!effortMenuOpen) return; - const handler = (e: MouseEvent) => { - if (effortMenuRef.current && !effortMenuRef.current.contains(e.target as Node)) { - setEffortMenuOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [effortMenuOpen]); - // Map isStreaming to ChatStatus for PromptInputSubmit const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready'; diff --git a/src/components/chat/ModelSelectorDropdown.tsx b/src/components/chat/ModelSelectorDropdown.tsx new file mode 100644 index 0000000..39cc3f2 --- /dev/null +++ b/src/components/chat/ModelSelectorDropdown.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useRef, useState, useEffect, useCallback } from 'react'; +import { CaretDown, Gear } from '@phosphor-icons/react'; +import { cn } from '@/lib/utils'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; +import { PromptInputButton } from '@/components/ai-elements/prompt-input'; +import type { ProviderModelGroup } from '@/types'; + +interface ModelOption { + value: string; + label: string; + supportsEffort?: boolean; + supportedEffortLevels?: string[]; +} + +interface ModelSelectorDropdownProps { + currentModelValue: string; + currentProviderIdValue: string; + providerGroups: ProviderModelGroup[]; + modelOptions: ModelOption[]; + onModelChange?: (model: string) => void; + onProviderModelChange?: (providerId: string, model: string) => void; +} + +export function ModelSelectorDropdown({ + currentModelValue, + currentProviderIdValue, + providerGroups, + modelOptions, + onModelChange, + onProviderModelChange, +}: ModelSelectorDropdownProps) { + const { t } = useTranslation(); + const modelMenuRef = useRef(null); + const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [modelSearch, setModelSearch] = useState(''); + + const currentModelOption = modelOptions.find((m) => m.value === currentModelValue) || modelOptions[0]; + + // Click outside to close model menu + useEffect(() => { + if (!modelMenuOpen) return; + const handler = (e: MouseEvent) => { + if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) { + setModelMenuOpen(false); + setModelSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [modelMenuOpen]); + + const handleModelSelect = useCallback((providerId: string, modelValue: string) => { + onModelChange?.(modelValue); + onProviderModelChange?.(providerId, modelValue); + localStorage.setItem('codepilot:last-model', modelValue); + localStorage.setItem('codepilot:last-provider-id', providerId); + setModelMenuOpen(false); + setModelSearch(''); + }, [onModelChange, onProviderModelChange]); + + const mq = modelSearch.toLowerCase(); + const filteredGroups = providerGroups.map(group => ({ + ...group, + models: group.models.filter(opt => + !mq || opt.label.toLowerCase().includes(mq) || group.provider_name.toLowerCase().includes(mq) + ), + })).filter(group => group.models.length > 0); + + return ( +
+ setModelMenuOpen((prev) => !prev)} + > + {currentModelOption?.label} + + + + {modelMenuOpen && ( +
+ {/* Search */} +
+ setModelSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + setModelMenuOpen(false); + setModelSearch(''); + } + }} + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + /> +
+ {/* Model list */} +
+ {filteredGroups.map((group, groupIdx) => ( +
+
0 && "border-t" + )}> + {group.provider_name} +
+
+ {group.models.map((opt) => { + const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue; + return ( + + ); + })} +
+
+ ))} + {filteredGroups.length === 0 && ( +
+ No models found +
+ )} +
+ {/* Footer: manage providers */} +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/chat/SlashCommandPopover.tsx b/src/components/chat/SlashCommandPopover.tsx new file mode 100644 index 0000000..d67f7ac --- /dev/null +++ b/src/components/chat/SlashCommandPopover.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useCallback } from 'react'; +import { + At, + Terminal, + NotePencil, + Brain, + GlobeSimple, + Lightning, +} from '@phosphor-icons/react'; +import type { Icon } from '@phosphor-icons/react'; +import { cn } from '@/lib/utils'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; +import type { SkillKind } from '@/types'; + +export interface PopoverItem { + label: string; + value: string; + description?: string; + descriptionKey?: TranslationKey; + builtIn?: boolean; + immediate?: boolean; + installedSource?: 'agents' | 'claude'; + source?: 'global' | 'project' | 'plugin' | 'installed' | 'sdk'; + kind?: SkillKind; + icon?: Icon; +} + +export type PopoverMode = 'file' | 'skill' | 'cli' | null; + +interface SlashCommandPopoverProps { + popoverMode: PopoverMode; + popoverRef: React.RefObject; + filteredItems: PopoverItem[]; + aiSuggestions: PopoverItem[]; + aiSearchLoading: boolean; + selectedIndex: number; + popoverFilter: string; + inputValue: string; + triggerPos: number | null; + searchInputRef: React.RefObject; + allDisplayedItems: PopoverItem[]; + onInsertItem: (item: PopoverItem) => void; + onSetSelectedIndex: (index: number) => void; + onSetPopoverFilter: (filter: string) => void; + onSetInputValue: (value: string) => void; + onClosePopover: () => void; + onFocusTextarea: () => void; +} + +export function SlashCommandPopover({ + popoverMode, + popoverRef, + filteredItems, + aiSuggestions, + aiSearchLoading, + selectedIndex, + popoverFilter, + inputValue, + triggerPos, + searchInputRef, + allDisplayedItems, + onInsertItem, + onSetSelectedIndex, + onSetPopoverFilter, + onSetInputValue, + onClosePopover, + onFocusTextarea, +}: SlashCommandPopoverProps) { + const { t } = useTranslation(); + + const builtInItems = filteredItems.filter(item => item.builtIn); + const slashCommandItems = filteredItems.filter(item => !item.builtIn && item.kind !== 'agent_skill'); + const agentSkillItems = filteredItems.filter(item => !item.builtIn && item.kind === 'agent_skill'); + let globalIdx = 0; + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + onSetSelectedIndex((selectedIndex + 1) % allDisplayedItems.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + onSetSelectedIndex((selectedIndex - 1 + allDisplayedItems.length) % allDisplayedItems.length); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + if (allDisplayedItems[selectedIndex]) { + onInsertItem(allDisplayedItems[selectedIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + onClosePopover(); + onFocusTextarea(); + } + }, [selectedIndex, allDisplayedItems, onSetSelectedIndex, onInsertItem, onClosePopover, onFocusTextarea]); + + const handleFilterChange = useCallback((e: React.ChangeEvent) => { + const val = e.target.value; + onSetPopoverFilter(val); + onSetSelectedIndex(0); + // Sync textarea: replace the filter portion after / + if (triggerPos !== null) { + const before = inputValue.slice(0, triggerPos + 1); + onSetInputValue(before + val); + } + }, [triggerPos, inputValue, onSetPopoverFilter, onSetSelectedIndex, onSetInputValue]); + + const renderItem = (item: PopoverItem, idx: number) => ( + + ); + + if (!popoverMode || popoverMode === 'cli') return null; + if (allDisplayedItems.length === 0 && !aiSearchLoading) return null; + + return ( +
+ {popoverMode === 'skill' ? ( +
+ +
+ ) : ( +
+ Files +
+ )} +
+ {popoverMode === 'file' ? ( + filteredItems.map((item, i) => renderItem(item, i)) + ) : ( + <> + {builtInItems.length > 0 && ( + <> +
+ Commands +
+ {builtInItems.map((item) => { + const idx = globalIdx++; + return renderItem(item, idx); + })} + + )} + {slashCommandItems.length > 0 && ( + <> +
+ Slash Commands +
+ {slashCommandItems.map((item) => { + const idx = globalIdx++; + return renderItem(item, idx); + })} + + )} + {agentSkillItems.length > 0 && ( + <> +
+ Agent Skills +
+ {agentSkillItems.map((item) => { + const idx = globalIdx++; + return renderItem(item, idx); + })} + + )} + {/* AI Suggested section */} + {(aiSuggestions.length > 0 || aiSearchLoading) && ( + <> +
+ + {t('messageInput.aiSuggested')} + {aiSearchLoading && ( + + )} +
+ {aiSuggestions.map((item) => { + const idx = globalIdx++; + return renderItem(item, idx); + })} + + )} + + )} +
+ {/* Footer: manage skills (skill mode only) */} + {popoverMode === 'skill' && ( +
+ +
+ )} +
+ ); +}