feat: CLI Tools — curated tool catalog, detection, install, chat awareness
New sidebar page for managing system CLI tools (ffmpeg, jq, ripgrep, yt-dlp, pandoc).
Core modules:
- cli-tools-catalog.ts: static catalog of 6 curated tools + EXTRA_WELL_KNOWN_BINS
- cli-tools-detect.ts: system detection via which/where + --version (2min cache)
- cli-tools-context.ts: builds <available_cli_tools> block for chat system prompt
API routes:
- GET /api/cli-tools/catalog, /installed, /[id]/status, /[id]/detail
- POST /api/cli-tools/[id]/install (SSE streaming), /[id]/describe (AI gen)
UI components:
- CliToolsManager: installed + recommended sections with grid layout
- CliToolCard: two variants (installed/recommended) with status badges
- CliToolDetailDialog: intro, use cases, guide steps, example prompts
- CliToolInstallDialog: SSE-based install progress with live terminal log
Chat integration:
- Chat input toolbar: Terminal icon opens CLI tool picker popover
- System prompt injection: buildCliToolsContext() in /api/chat
- MessageInput: CLI popover with search, keyboard nav, locale-aware prefill
- NavRail + ChatListPanel: frosted glass (bg-sidebar/80 backdrop-blur-xl)
- SlashCommandButton: Lightning icon, unified 16px toolbar icons
- input-group: shadow-md → shadow-sm
- Model selector: searchable popover with provider grouping
Docs:
- docs/handover/cli-tools.md + apps/site/content/docs/{en,zh}/cli-tools.mdx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e603323c4
commit
16edf9f59c
37 files changed
+3108
-78
No files matched your search
@@ -18,6 +18,8 @@ import {
|
||||
Brain,
|
||||
GlobeSimple,
|
||||
Stop,
|
||||
Lightning,
|
||||
Gear,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -36,6 +38,11 @@ import type { ChatStatus } from 'ai';
|
||||
import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SlashCommandButton } from './SlashCommandButton';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useImageGen } from '@/hooks/useImageGen';
|
||||
import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store';
|
||||
|
||||
@@ -126,7 +133,19 @@ interface CommandBadge {
|
||||
installedSource?: "agents" | "claude";
|
||||
}
|
||||
|
||||
type PopoverMode = 'file' | 'skill' | null;
|
||||
interface CliBadge {
|
||||
id: string;
|
||||
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.
|
||||
@@ -236,7 +255,7 @@ function AttachFileButton() {
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
tooltip={t('messageInput.attachFiles')}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<Plus size={16} />
|
||||
</PromptInputButton>
|
||||
);
|
||||
}
|
||||
@@ -327,7 +346,7 @@ export function MessageInput({
|
||||
onEffortChange,
|
||||
sdkInitMeta,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, locale } = useTranslation();
|
||||
const imageGen = useImageGen();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -340,8 +359,13 @@ export function MessageInput({
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [triggerPos, setTriggerPos] = useState<number | null>(null);
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [badge, setBadge] = useState<CommandBadge | null>(null);
|
||||
const [cliBadge, setCliBadge] = useState<CliBadge | null>(null);
|
||||
const [cliTools, setCliTools] = useState<CliToolItem[]>([]);
|
||||
const [cliFilter, setCliFilter] = useState('');
|
||||
const cliSearchRef = useRef<HTMLInputElement>(null);
|
||||
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string>('');
|
||||
const [aiSuggestions, setAiSuggestions] = useState<PopoverItem[]>([]);
|
||||
@@ -525,6 +549,81 @@ export function MessageInput({
|
||||
return [...BUILT_IN_COMMANDS, ...uniqueSkills];
|
||||
}, [workingDirectory, sdkInitMeta]);
|
||||
|
||||
// Fetch CLI tools for CLI popover
|
||||
const fetchCliTools = useCallback(async () => {
|
||||
try {
|
||||
const [installedRes, catalogRes] = await Promise.all([
|
||||
fetch('/api/cli-tools/installed'),
|
||||
fetch('/api/cli-tools/catalog'),
|
||||
]);
|
||||
const installedData = await installedRes.json();
|
||||
const catalogData = await catalogRes.json();
|
||||
|
||||
const catalogTools = catalogData.tools || [];
|
||||
const runtimeInfos = installedData.tools || [];
|
||||
const extraDetected = installedData.extra || [];
|
||||
|
||||
// Build lookup for catalog summaries
|
||||
const catalogMap = new Map<string, { name: string; summaryZh: string; summaryEn: string }>();
|
||||
for (const ct of catalogTools) {
|
||||
catalogMap.set(ct.id, { name: ct.name, summaryZh: ct.summaryZh, summaryEn: ct.summaryEn });
|
||||
}
|
||||
|
||||
// Extra well-known names lookup
|
||||
const extraNames: Record<string, string> = {};
|
||||
try {
|
||||
const { EXTRA_WELL_KNOWN_BINS } = await import('@/lib/cli-tools-catalog');
|
||||
for (const [id, name] of EXTRA_WELL_KNOWN_BINS) {
|
||||
extraNames[id] = name;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Load cached AI descriptions
|
||||
let autoDesc: Record<string, { zh: string; en: string }> = {};
|
||||
try {
|
||||
const cached = localStorage.getItem('cli-tools-auto-desc');
|
||||
if (cached) autoDesc = JSON.parse(cached);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const locale = document.documentElement.lang === 'zh' ? 'zh' : 'en';
|
||||
const items: CliToolItem[] = [];
|
||||
|
||||
// Installed catalog tools
|
||||
for (const ri of runtimeInfos) {
|
||||
if (ri.status !== 'installed') continue;
|
||||
const cat = catalogMap.get(ri.id);
|
||||
const ad = autoDesc[ri.id];
|
||||
const summary = ad
|
||||
? (locale === 'zh' ? ad.zh : ad.en)
|
||||
: cat
|
||||
? (locale === 'zh' ? cat.summaryZh : cat.summaryEn)
|
||||
: '';
|
||||
items.push({
|
||||
id: ri.id,
|
||||
name: cat?.name || ri.id,
|
||||
version: ri.version,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
// Extra detected tools
|
||||
for (const ri of extraDetected) {
|
||||
const ad = autoDesc[ri.id];
|
||||
const summary = ad ? (locale === 'zh' ? ad.zh : ad.en) : '';
|
||||
items.push({
|
||||
id: ri.id,
|
||||
name: extraNames[ri.id] || ri.id,
|
||||
version: ri.version,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
setCliTools(items);
|
||||
} catch {
|
||||
setCliTools([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close popover
|
||||
const closePopover = useCallback(() => {
|
||||
setPopoverMode(null);
|
||||
@@ -551,6 +650,52 @@ export function MessageInput({
|
||||
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
// Remove CLI badge
|
||||
const removeCliBadge = useCallback(() => {
|
||||
setCliBadge(null);
|
||||
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
// Handle CLI tool selection from popover
|
||||
const handleCliSelect = useCallback((tool: CliToolItem) => {
|
||||
closePopover();
|
||||
setCliFilter('');
|
||||
|
||||
if (!inputValue.trim()) {
|
||||
// Empty input: prefill with prompt template
|
||||
const prefix = locale === 'zh'
|
||||
? `我想用 ${tool.name} 工具完成:`
|
||||
: `I want to use ${tool.name} to: `;
|
||||
setInputValue(prefix);
|
||||
setTimeout(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = prefix.length;
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
// Non-empty input: set CLI badge
|
||||
setCliBadge({ id: tool.id, name: tool.name });
|
||||
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||
}
|
||||
}, [inputValue, locale, closePopover]);
|
||||
|
||||
// Open CLI popover
|
||||
const handleOpenCliPopover = useCallback(async () => {
|
||||
if (popoverMode === 'cli') {
|
||||
closePopover();
|
||||
return;
|
||||
}
|
||||
closePopover();
|
||||
setPopoverMode('cli');
|
||||
setCliFilter('');
|
||||
setSelectedIndex(0);
|
||||
// Focus search input on next render (before fetch completes)
|
||||
setTimeout(() => cliSearchRef.current?.focus(), 0);
|
||||
fetchCliTools();
|
||||
}, [popoverMode, closePopover, fetchCliTools]);
|
||||
|
||||
// Insert selected item
|
||||
const insertItem = useCallback((item: PopoverItem) => {
|
||||
if (triggerPos === null) return;
|
||||
@@ -628,7 +773,8 @@ export function MessageInput({
|
||||
return;
|
||||
}
|
||||
|
||||
if (popoverMode) {
|
||||
// Only auto-close text-triggered popovers (file/skill); CLI is button-triggered
|
||||
if (popoverMode && popoverMode !== 'cli') {
|
||||
closePopover();
|
||||
}
|
||||
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
|
||||
@@ -782,9 +928,15 @@ export function MessageInput({
|
||||
}
|
||||
}
|
||||
|
||||
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined);
|
||||
// If CLI badge is active, inject systemPromptAppend to guide model
|
||||
const cliAppend = cliBadge
|
||||
? `The user wants to use the installed CLI tool "${cliBadge.name}" if appropriate for this task. Prefer using "${cliBadge.name}" when suitable.`
|
||||
: undefined;
|
||||
if (cliBadge) setCliBadge(null);
|
||||
|
||||
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined, cliAppend);
|
||||
setInputValue('');
|
||||
}, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen]);
|
||||
}, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, cliBadge, imageGen]);
|
||||
|
||||
const filteredItems = popoverItems.filter((item) => {
|
||||
const q = popoverFilter.toLowerCase();
|
||||
@@ -891,8 +1043,8 @@ export function MessageInput({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Popover navigation
|
||||
if (popoverMode && popoverItems.length > 0) {
|
||||
// Popover navigation (skill/file mode)
|
||||
if (popoverMode && popoverMode !== 'cli' && popoverItems.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % allDisplayedItems.length);
|
||||
@@ -917,21 +1069,51 @@ export function MessageInput({
|
||||
}
|
||||
}
|
||||
|
||||
// CLI popover keyboard navigation
|
||||
if (popoverMode === 'cli') {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePopover();
|
||||
return;
|
||||
}
|
||||
if (cliTools.length > 0) {
|
||||
const q = cliFilter.toLowerCase();
|
||||
const filtered = cliTools.filter(t =>
|
||||
t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q)
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered[selectedIndex]) handleCliSelect(filtered[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace removes badge when input is empty
|
||||
if (e.key === 'Backspace' && badge && !inputValue) {
|
||||
e.preventDefault();
|
||||
removeBadge();
|
||||
return;
|
||||
if (e.key === 'Backspace' && !inputValue) {
|
||||
if (badge) { e.preventDefault(); removeBadge(); return; }
|
||||
if (cliBadge) { e.preventDefault(); removeCliBadge(); return; }
|
||||
}
|
||||
|
||||
// Escape removes badge
|
||||
if (e.key === 'Escape' && badge) {
|
||||
e.preventDefault();
|
||||
removeBadge();
|
||||
return;
|
||||
if (e.key === 'Escape') {
|
||||
if (badge) { e.preventDefault(); removeBadge(); return; }
|
||||
if (cliBadge) { e.preventDefault(); removeCliBadge(); return; }
|
||||
}
|
||||
},
|
||||
[popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge, allDisplayedItems]
|
||||
[popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, cliBadge, inputValue, removeBadge, removeCliBadge, allDisplayedItems, cliTools, cliFilter, handleCliSelect]
|
||||
);
|
||||
|
||||
// Click outside to close popover
|
||||
@@ -952,6 +1134,7 @@ export function MessageInput({
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) {
|
||||
setModelMenuOpen(false);
|
||||
setModelSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
@@ -1041,7 +1224,7 @@ export function MessageInput({
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
|
||||
className="absolute bottom-full left-0 mb-2 w-full max-w-2xl rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
|
||||
>
|
||||
{popoverMode === 'skill' ? (
|
||||
<div className="px-3 py-2 border-b">
|
||||
@@ -1079,7 +1262,6 @@ export function MessageInput({
|
||||
}
|
||||
}}
|
||||
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1144,6 +1326,105 @@ export function MessageInput({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Footer: manage skills (skill mode only) */}
|
||||
{popoverMode === 'skill' && (
|
||||
<div className="border-t px-3 py-1.5">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
onClick={() => { closePopover(); window.location.href = '/skills'; }}
|
||||
>
|
||||
<Lightning size={14} />
|
||||
{t('composer.manageSkills' as TranslationKey)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* CLI Tools Popover */}
|
||||
{popoverMode === 'cli' && (() => {
|
||||
const q = cliFilter.toLowerCase();
|
||||
const filtered = cliTools.filter(t =>
|
||||
t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-0 mb-2 w-full max-w-2xl rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
|
||||
>
|
||||
<div className="px-3 py-2 border-b">
|
||||
<input
|
||||
ref={cliSearchRef}
|
||||
type="text"
|
||||
placeholder={t('cliTools.searchPlaceholder' as TranslationKey)}
|
||||
value={cliFilter}
|
||||
onChange={(e) => { setCliFilter(e.target.value); setSelectedIndex(0); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered[selectedIndex]) handleCliSelect(filtered[selectedIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePopover();
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((tool, idx) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
ref={idx === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
idx === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => handleCliSelect(tool)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<Terminal size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium text-xs truncate">{tool.name}</span>
|
||||
{tool.version && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">v{tool.version}</span>
|
||||
)}
|
||||
{tool.summary && (
|
||||
<span className="text-xs text-muted-foreground truncate ml-auto max-w-[200px]">{tool.summary}</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('cliTools.noToolsDetected' as TranslationKey)}</p>
|
||||
<button
|
||||
className="mt-2 text-xs text-primary hover:underline"
|
||||
onClick={() => { closePopover(); window.location.href = '/cli-tools'; }}
|
||||
>
|
||||
{t('cliTools.goInstall' as TranslationKey)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Footer: manage CLI tools */}
|
||||
<div className="border-t px-3 py-1.5">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
onClick={() => { closePopover(); window.location.href = '/cli-tools'; }}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{t('cliTools.manageCli' as TranslationKey)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -1176,11 +1457,29 @@ export function MessageInput({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* CLI badge */}
|
||||
{cliBadge && (
|
||||
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-emerald-500/20">
|
||||
<Terminal size={12} />
|
||||
<span>CLI: {cliBadge.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCliBadge}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-emerald-500/20 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 3l6 6M9 3l-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* File attachment capsules */}
|
||||
<FileAttachmentsCapsules />
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
placeholder={badge ? "Add details (optional), then press Enter..." : "Message Claude..."}
|
||||
placeholder={badge ? "Add details (optional), then press Enter..." : cliBadge ? "Describe what you want to do..." : "Message Claude..."}
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -1196,6 +1495,18 @@ export function MessageInput({
|
||||
{/* Slash command button */}
|
||||
<SlashCommandButton onInsertSlash={handleInsertSlash} />
|
||||
|
||||
{/* CLI tools button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PromptInputButton onClick={handleOpenCliPopover}>
|
||||
<Terminal size={16} />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('cliTools.selectTool' as TranslationKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="relative" ref={modelMenuRef}>
|
||||
<PromptInputButton
|
||||
@@ -1205,46 +1516,90 @@ export function MessageInput({
|
||||
<CaretDown size={10} className={cn("transition-transform duration-200", modelMenuOpen && "rotate-180")} />
|
||||
</PromptInputButton>
|
||||
|
||||
{modelMenuOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-52 rounded-lg border bg-popover shadow-lg overflow-hidden z-50 max-h-80 overflow-y-auto">
|
||||
{providerGroups.map((group, groupIdx) => (
|
||||
<div key={group.provider_id}>
|
||||
{/* Group header */}
|
||||
<div className={cn(
|
||||
"px-3 py-1.5 text-[10px] font-medium text-muted-foreground",
|
||||
groupIdx > 0 && "border-t"
|
||||
)}>
|
||||
{group.provider_name}
|
||||
</div>
|
||||
{/* Models in group */}
|
||||
<div className="py-0.5">
|
||||
{group.models.map((opt) => {
|
||||
const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue;
|
||||
return (
|
||||
<button
|
||||
key={`${group.provider_id}-${opt.value}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
onModelChange?.(opt.value);
|
||||
onProviderModelChange?.(group.provider_id, opt.value);
|
||||
localStorage.setItem('codepilot:last-model', opt.value);
|
||||
localStorage.setItem('codepilot:last-provider-id', group.provider_id);
|
||||
setModelMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-xs">{opt.label}</span>
|
||||
{isActive && <span className="text-xs">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{modelMenuOpen && (() => {
|
||||
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 (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-64 rounded-xl border bg-popover shadow-lg overflow-hidden z-50">
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 border-b">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('composer.searchModels' as TranslationKey)}
|
||||
value={modelSearch}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Model list */}
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{filteredGroups.map((group, groupIdx) => (
|
||||
<div key={group.provider_id}>
|
||||
<div className={cn(
|
||||
"px-3 py-1.5 text-[10px] font-medium text-muted-foreground",
|
||||
groupIdx > 0 && "border-t"
|
||||
)}>
|
||||
{group.provider_name}
|
||||
</div>
|
||||
<div className="py-0.5">
|
||||
{group.models.map((opt) => {
|
||||
const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue;
|
||||
return (
|
||||
<button
|
||||
key={`${group.provider_id}-${opt.value}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
onModelChange?.(opt.value);
|
||||
onProviderModelChange?.(group.provider_id, opt.value);
|
||||
localStorage.setItem('codepilot:last-model', opt.value);
|
||||
localStorage.setItem('codepilot:last-provider-id', group.provider_id);
|
||||
setModelMenuOpen(false);
|
||||
setModelSearch('');
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-xs">{opt.label}</span>
|
||||
{isActive && <span className="text-xs">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
No models found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Footer: manage providers */}
|
||||
<div className="border-t px-3 py-1.5">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
onClick={() => { setModelMenuOpen(false); setModelSearch(''); window.location.href = '/settings'; }}
|
||||
>
|
||||
<Gear size={14} />
|
||||
{t('composer.manageProviders' as TranslationKey)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Effort selector — only visible when model supports effort */}
|
||||
@@ -1292,7 +1647,7 @@ export function MessageInput({
|
||||
onStop={onStop}
|
||||
disabled={disabled}
|
||||
inputValue={inputValue}
|
||||
hasBadge={!!badge}
|
||||
hasBadge={!!badge || !!cliBadge}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { TranslationKey } from '@/i18n';
|
||||
import { Lightning } from '@phosphor-icons/react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -20,7 +21,7 @@ export function SlashCommandButton({ onInsertSlash }: SlashCommandButtonProps) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PromptInputButton onClick={onInsertSlash}>
|
||||
<span className="text-sm font-medium leading-none">/</span>
|
||||
<Lightning size={16} />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SpinnerGap, CheckCircle, XCircle, Sparkle } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
|
||||
interface ProviderModelGroup {
|
||||
provider_id: string;
|
||||
provider_name: string;
|
||||
sdkProxyOnly?: boolean;
|
||||
models: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
type AutoDescCache = Record<string, { zh: string; en: string }>;
|
||||
|
||||
interface CliToolBatchDescribeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
toolIds: string[];
|
||||
existingDescriptions: AutoDescCache;
|
||||
onComplete: (results: AutoDescCache) => void;
|
||||
}
|
||||
|
||||
type Phase = "select" | "running" | "done";
|
||||
|
||||
interface ToolResult {
|
||||
id: string;
|
||||
status: 'pending' | 'loading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function CliToolBatchDescribeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
toolIds,
|
||||
existingDescriptions,
|
||||
onComplete,
|
||||
}: CliToolBatchDescribeDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("select");
|
||||
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [toolResults, setToolResults] = useState<ToolResult[]>([]);
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
const resultsRef = useRef<AutoDescCache>({});
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch available models when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPhase("select");
|
||||
abortControllerRef.current = null;
|
||||
resultsRef.current = {};
|
||||
setToolResults([]);
|
||||
|
||||
fetch("/api/providers/models")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const allGroups: ProviderModelGroup[] = data.groups || [];
|
||||
// All providers are supported — describe uses the same SDK path as chat
|
||||
const groups = allGroups;
|
||||
const defaultPid: string = data.default_provider_id || '';
|
||||
setProviderGroups(groups);
|
||||
// Auto-select the user's default provider (if not filtered), or fall back to first group
|
||||
const defaultGroup = groups.find(g => g.provider_id === defaultPid) || groups[0];
|
||||
if (defaultGroup) {
|
||||
setSelectedProviderId(defaultGroup.provider_id);
|
||||
if (defaultGroup.models.length > 0) {
|
||||
setSelectedModel(defaultGroup.models[0].value);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to fetch models:", err));
|
||||
}, [open]);
|
||||
|
||||
const selectedGroup = providerGroups.find(g => g.provider_id === selectedProviderId);
|
||||
const models = selectedGroup?.models || [];
|
||||
|
||||
const handleProviderChange = (pid: string) => {
|
||||
setSelectedProviderId(pid);
|
||||
const group = providerGroups.find(g => g.provider_id === pid);
|
||||
if (group && group.models.length > 0) {
|
||||
setSelectedModel(group.models[0].value);
|
||||
} else {
|
||||
setSelectedModel("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
const idsToProcess = skipExisting
|
||||
? toolIds.filter(id => !existingDescriptions[id])
|
||||
: [...toolIds];
|
||||
|
||||
if (idsToProcess.length === 0) {
|
||||
setPhase("done");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
setPhase("running");
|
||||
setToolResults(idsToProcess.map(id => ({ id, status: 'pending' })));
|
||||
|
||||
for (let i = 0; i < idsToProcess.length; i++) {
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
const toolId = idsToProcess[i];
|
||||
setToolResults(prev => prev.map((r, idx) =>
|
||||
idx === i ? { ...r, status: 'loading' } : r
|
||||
));
|
||||
|
||||
try {
|
||||
// Always pass providerId so the server uses exactly what the user selected.
|
||||
// 'env' is a valid providerId that resolveProvider understands.
|
||||
const body: Record<string, string> = {
|
||||
providerId: selectedProviderId,
|
||||
};
|
||||
if (selectedModel) {
|
||||
body.model = selectedModel;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/cli-tools/${toolId}/describe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
resultsRef.current[toolId] = data.description;
|
||||
|
||||
setToolResults(prev => prev.map((r, idx) =>
|
||||
idx === i ? { ...r, status: 'success' } : r
|
||||
));
|
||||
} catch (err) {
|
||||
// Don't record abort errors as tool failures
|
||||
if (controller.signal.aborted) break;
|
||||
setToolResults(prev => prev.map((r, idx) =>
|
||||
idx === i ? { ...r, status: 'error', error: err instanceof Error ? err.message : 'Failed' } : r
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Only commit results if not cancelled
|
||||
if (!controller.signal.aborted && Object.keys(resultsRef.current).length > 0) {
|
||||
onComplete(resultsRef.current);
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
setPhase("done");
|
||||
}
|
||||
}, [toolIds, existingDescriptions, skipExisting, selectedProviderId, selectedModel, onComplete]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Abort any in-flight requests
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const successCount = toolResults.filter(r => r.status === 'success').length;
|
||||
const errorCount = toolResults.filter(r => r.status === 'error').length;
|
||||
|
||||
const toolsToProcessCount = skipExisting
|
||||
? toolIds.filter(id => !existingDescriptions[id]).length
|
||||
: toolIds.length;
|
||||
const existingCount = toolIds.filter(id => !!existingDescriptions[id]).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkle size={18} />
|
||||
{t('cliTools.batchDescribe')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cliTools.batchDescribeIntro')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{phase === "select" && providerGroups.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
{t('cliTools.batchNoProvider' as TranslationKey)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{phase === "select" && providerGroups.length > 0 && (
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{/* Provider selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t('cliTools.batchProvider')}
|
||||
</label>
|
||||
<Select value={selectedProviderId} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerGroups.map(g => (
|
||||
<SelectItem key={g.provider_id} value={g.provider_id}>
|
||||
{g.provider_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t('cliTools.batchModel')}
|
||||
</label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map(m => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Skip existing toggle */}
|
||||
{existingCount > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipExisting}
|
||||
onChange={e => setSkipExisting(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{t('cliTools.batchSkipExisting', { count: String(existingCount) })}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('cliTools.batchToolCount', { count: String(toolsToProcessCount), total: String(toolIds.length) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "running" && (
|
||||
<div className="flex flex-col gap-2 py-2 max-h-64 overflow-y-auto">
|
||||
{toolResults.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-sm">
|
||||
{r.status === 'pending' && (
|
||||
<span className="w-4 h-4 rounded-full border border-muted-foreground/30 shrink-0" />
|
||||
)}
|
||||
{r.status === 'loading' && (
|
||||
<SpinnerGap size={16} className="animate-spin text-blue-500 shrink-0" />
|
||||
)}
|
||||
{r.status === 'success' && (
|
||||
<CheckCircle size={16} weight="fill" className="text-green-500 shrink-0" />
|
||||
)}
|
||||
{r.status === 'error' && (
|
||||
<XCircle size={16} weight="fill" className="text-destructive shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{r.id}</span>
|
||||
{r.status === 'error' && r.error && (
|
||||
<span className="text-xs text-destructive truncate ml-auto">{r.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{successCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle size={16} weight="fill" />
|
||||
{t('cliTools.batchSuccess', { count: String(successCount) })}
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<XCircle size={16} weight="fill" />
|
||||
{t('cliTools.batchFailed', { count: String(errorCount) })}
|
||||
</span>
|
||||
)}
|
||||
{successCount === 0 && errorCount === 0 && (
|
||||
<span className="text-muted-foreground">{t('cliTools.batchNothingToProcess')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{phase === "select" && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
{t('cliTools.cancel')}
|
||||
</Button>
|
||||
{providerGroups.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedModel || toolsToProcessCount === 0}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkle size={14} />
|
||||
{t('cliTools.batchStart')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{phase === "running" && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
{t('cliTools.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{phase === "done" && (
|
||||
<Button size="sm" onClick={handleClose}>
|
||||
{t('cliTools.done')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, CaretDown } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import type { CliToolDefinition, CliToolRuntimeInfo, CliToolPlatform } from "@/types";
|
||||
|
||||
interface CliToolCardProps {
|
||||
tool: CliToolDefinition;
|
||||
runtimeInfo?: CliToolRuntimeInfo;
|
||||
variant: 'installed' | 'recommended';
|
||||
autoDescription?: { zh: string; en: string };
|
||||
onDetail: () => void;
|
||||
onInstall?: (tool: CliToolDefinition, method: string) => void;
|
||||
locale: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export function CliToolCard({
|
||||
tool,
|
||||
runtimeInfo,
|
||||
variant,
|
||||
autoDescription,
|
||||
onDetail,
|
||||
onInstall,
|
||||
locale,
|
||||
platform,
|
||||
}: CliToolCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showMethodPicker, setShowMethodPicker] = useState(false);
|
||||
const isZh = locale === 'zh';
|
||||
|
||||
const availableMethods = tool.installMethods.filter(
|
||||
m => m.platforms.includes(platform as CliToolPlatform)
|
||||
);
|
||||
|
||||
const summary = autoDescription
|
||||
? (isZh ? autoDescription.zh : autoDescription.en)
|
||||
: (isZh ? tool.summaryZh : tool.summaryEn);
|
||||
|
||||
const handleInstallClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (availableMethods.length === 1) {
|
||||
onInstall?.(tool, availableMethods[0].method);
|
||||
} else if (availableMethods.length > 1) {
|
||||
setShowMethodPicker(!showMethodPicker);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMethodSelect = (e: React.MouseEvent, method: string) => {
|
||||
e.stopPropagation();
|
||||
setShowMethodPicker(false);
|
||||
onInstall?.(tool, method);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg border border-border/40 px-3 py-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={onDetail}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
|
||||
{/* Category tags inline */}
|
||||
{tool.categories.map(cat => (
|
||||
<span
|
||||
key={cat}
|
||||
className="inline-block rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground shrink-0"
|
||||
>
|
||||
{t(`cliTools.category.${cat}` as TranslationKey)}
|
||||
</span>
|
||||
))}
|
||||
{/* Version for installed */}
|
||||
{variant === 'installed' && runtimeInfo?.version && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
v{runtimeInfo.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{summary || t('cliTools.noDescription' as TranslationKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right action */}
|
||||
{variant === 'recommended' && onInstall && availableMethods.length > 0 && (
|
||||
<div className="shrink-0 relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleInstallClick}
|
||||
title={t('cliTools.install')}
|
||||
>
|
||||
{availableMethods.length > 1
|
||||
? <CaretDown size={16} />
|
||||
: <Plus size={16} />}
|
||||
</Button>
|
||||
{showMethodPicker && availableMethods.length > 1 && (
|
||||
<div className="absolute right-0 top-8 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[140px]">
|
||||
{availableMethods.map(m => (
|
||||
<button
|
||||
key={m.method}
|
||||
className="w-full text-left px-2 py-1 text-xs rounded hover:bg-muted transition-colors"
|
||||
onClick={(e) => handleMethodSelect(e, m.method)}
|
||||
>
|
||||
{m.method}: {m.command}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, ArrowSquareOut, Plus, CaretDown } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { CliToolDefinition, CliToolPlatform } from "@/types";
|
||||
|
||||
interface CliToolDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tool: CliToolDefinition;
|
||||
locale: string;
|
||||
/** If provided, shows install button at the bottom (for recommended tools) */
|
||||
onInstall?: (tool: CliToolDefinition, method: string) => void;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export function CliToolDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tool,
|
||||
locale,
|
||||
onInstall,
|
||||
platform,
|
||||
}: CliToolDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const isZh = locale === 'zh';
|
||||
const [showMethodPicker, setShowMethodPicker] = useState(false);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const availableMethods = platform
|
||||
? tool.installMethods.filter(m => m.platforms.includes(platform as CliToolPlatform))
|
||||
: tool.installMethods;
|
||||
|
||||
const handleInstallClick = () => {
|
||||
if (availableMethods.length === 1) {
|
||||
onInstall?.(tool, availableMethods[0].method);
|
||||
} else if (availableMethods.length > 1) {
|
||||
setShowMethodPicker(!showMethodPicker);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tool.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Intro */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-2">{t('cliTools.detailIntro')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isZh ? tool.detailIntro.zh : tool.detailIntro.en}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Use cases */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-2">{t('cliTools.useCases')}</h3>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
{(isZh ? tool.useCases.zh : tool.useCases.en).map((uc, i) => (
|
||||
<li key={i}>{uc}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Guide steps */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-2">{t('cliTools.guideSteps')}</h3>
|
||||
<ol className="text-sm text-muted-foreground list-decimal list-inside space-y-1">
|
||||
{(isZh ? tool.guideSteps.zh : tool.guideSteps.en).map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Example prompts */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-2">{t('cliTools.examplePrompts')}</h3>
|
||||
<div className="space-y-2">
|
||||
{tool.examplePrompts.map((ep, i) => (
|
||||
<div key={i} className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium mb-1">{ep.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isZh ? ep.promptZh : ep.promptEn}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(isZh ? ep.promptZh : ep.promptEn)}
|
||||
title={t('cliTools.copy')}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Links */}
|
||||
{(tool.homepage || tool.repoUrl || tool.officialDocsUrl) && (
|
||||
<section className="flex flex-wrap gap-2 pt-2 border-t">
|
||||
{tool.homepage && (
|
||||
<a
|
||||
href={tool.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{t('cliTools.homepage')}
|
||||
</a>
|
||||
)}
|
||||
{tool.repoUrl && (
|
||||
<a
|
||||
href={tool.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
)}
|
||||
{tool.officialDocsUrl && (
|
||||
<a
|
||||
href={tool.officialDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{t('cliTools.docs')}
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install button — only for recommended (not installed) tools */}
|
||||
{onInstall && availableMethods.length > 0 && (
|
||||
<DialogFooter className="relative">
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleInstallClick}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('cliTools.install')}
|
||||
{availableMethods.length > 1 && <CaretDown size={12} />}
|
||||
</Button>
|
||||
{showMethodPicker && availableMethods.length > 1 && (
|
||||
<div className="absolute right-0 bottom-10 z-10 rounded-md border bg-popover p-1 shadow-md min-w-[180px]">
|
||||
{availableMethods.map(m => (
|
||||
<button
|
||||
key={m.method}
|
||||
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setShowMethodPicker(false);
|
||||
onInstall(tool, m.method);
|
||||
}}
|
||||
>
|
||||
{m.method}: {m.command}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { CliToolRuntimeInfo } from "@/types";
|
||||
|
||||
interface CliToolExtraDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
displayName: string;
|
||||
runtimeInfo: CliToolRuntimeInfo;
|
||||
autoDescription?: { zh: string; en: string };
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function CliToolExtraDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
displayName,
|
||||
runtimeInfo,
|
||||
autoDescription,
|
||||
locale,
|
||||
}: CliToolExtraDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const isZh = locale === 'zh';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{displayName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* AI-generated description */}
|
||||
{autoDescription && (
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-2">{t('cliTools.detailIntro')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isZh ? autoDescription.zh : autoDescription.en}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Runtime info */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-2">{t('cliTools.toolInfo')}</h3>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{runtimeInfo.version && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-foreground/70">{t('cliTools.version')}:</span>
|
||||
<span>{runtimeInfo.version}</span>
|
||||
</div>
|
||||
)}
|
||||
{runtimeInfo.binPath && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-foreground/70 shrink-0">{t('cliTools.path')}:</span>
|
||||
<span className="break-all font-mono text-xs">{runtimeInfo.binPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!autoDescription && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
{t('cliTools.noDescription')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SpinnerGap, CheckCircle, X } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { CliToolDefinition } from "@/types";
|
||||
|
||||
interface CliToolInstallDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tool: CliToolDefinition;
|
||||
method: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
type Phase = "running" | "success" | "error";
|
||||
|
||||
export function CliToolInstallDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tool,
|
||||
method,
|
||||
onComplete,
|
||||
}: CliToolInstallDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("running");
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const startProcess = useCallback(async () => {
|
||||
setPhase("running");
|
||||
setLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/cli-tools/${tool.id}/install`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ method }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setPhase("error");
|
||||
setLogs((prev) => [...prev, `HTTP ${res.status}: ${res.statusText}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6);
|
||||
let data: string;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = raw;
|
||||
}
|
||||
|
||||
if (currentEvent === "output") {
|
||||
setLogs((prev) => [...prev, data]);
|
||||
} else if (currentEvent === "done") {
|
||||
setPhase("success");
|
||||
} else if (currentEvent === "error") {
|
||||
setPhase("error");
|
||||
setLogs((prev) => [...prev, `Error: ${data}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
setPhase("error");
|
||||
setLogs((prev) => [...prev, (err as Error).message]);
|
||||
}
|
||||
}
|
||||
}, [tool.id, method]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startProcess();
|
||||
}
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [open, startProcess]);
|
||||
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
const handleClose = () => {
|
||||
abortRef.current?.abort();
|
||||
if (phase === "success") {
|
||||
onComplete();
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const installMethod = tool.installMethods.find(m => m.method === method);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{phase === "running" && (
|
||||
<SpinnerGap size={20} className="animate-spin text-primary" />
|
||||
)}
|
||||
{phase === "success" && (
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<X size={20} className="text-red-500" />
|
||||
)}
|
||||
{phase === "running"
|
||||
? `${t('cliTools.installing')} ${tool.name}...`
|
||||
: phase === "success"
|
||||
? t('cliTools.installSuccess')
|
||||
: t('cliTools.installFailed')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{installMethod && (
|
||||
<div className="text-xs text-muted-foreground font-mono bg-muted/30 rounded px-2 py-1">
|
||||
$ {installMethod.command}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-md p-3 max-h-64 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||
{logs.length === 0 && phase === "running" && (
|
||||
<span className="text-muted-foreground">{t('cliTools.installing')}...</span>
|
||||
)}
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>
|
||||
{phase === "running" ? t('common.cancel') : t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import type { CliToolDefinition, CliToolRuntimeInfo } from "@/types";
|
||||
import { CliToolCard } from "./CliToolCard";
|
||||
import { CliToolDetailDialog } from "./CliToolDetailDialog";
|
||||
import { CliToolExtraDetailDialog } from "./CliToolExtraDetailDialog";
|
||||
import { CliToolInstallDialog } from "./CliToolInstallDialog";
|
||||
import { CliToolBatchDescribeDialog } from "./CliToolBatchDescribeDialog";
|
||||
import { SpinnerGap, Sparkle, ArrowSquareOut, Warning } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EXTRA_WELL_KNOWN_BINS } from "@/lib/cli-tools-catalog";
|
||||
|
||||
type AutoDescCache = Record<string, { zh: string; en: string }>;
|
||||
|
||||
export function CliToolsManager() {
|
||||
const { t, locale } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<CliToolDefinition[]>([]);
|
||||
const [runtimeInfos, setRuntimeInfos] = useState<CliToolRuntimeInfo[]>([]);
|
||||
const [extraDetected, setExtraDetected] = useState<CliToolRuntimeInfo[]>([]);
|
||||
const [platform, setPlatform] = useState<string>('');
|
||||
const [hasBrew, setHasBrew] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoDescriptions, setAutoDescriptions] = useState<AutoDescCache>({});
|
||||
|
||||
// Dialog state
|
||||
const [detailTool, setDetailTool] = useState<{ tool: CliToolDefinition; canInstall: boolean } | null>(null);
|
||||
const [extraDetailTool, setExtraDetailTool] = useState<{ displayName: string; runtimeInfo: CliToolRuntimeInfo } | null>(null);
|
||||
const [installTool, setInstallTool] = useState<{ tool: CliToolDefinition; method: string } | null>(null);
|
||||
const [batchDescribeOpen, setBatchDescribeOpen] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [catalogRes, installedRes] = await Promise.all([
|
||||
fetch('/api/cli-tools/catalog'),
|
||||
fetch('/api/cli-tools/installed'),
|
||||
]);
|
||||
const catalogData = await catalogRes.json();
|
||||
const installedData = await installedRes.json();
|
||||
setCatalog(catalogData.tools || []);
|
||||
setRuntimeInfos(installedData.tools || []);
|
||||
setExtraDetected(installedData.extra || []);
|
||||
setPlatform(installedData.platform || '');
|
||||
setHasBrew(installedData.hasBrew !== false);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch CLI tools data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// Load cached auto-descriptions from localStorage
|
||||
try {
|
||||
const cached = localStorage.getItem('cli-tools-auto-desc');
|
||||
if (cached) setAutoDescriptions(JSON.parse(cached));
|
||||
} catch { /* ignore */ }
|
||||
}, [fetchData]);
|
||||
|
||||
const getRuntimeInfo = (toolId: string): CliToolRuntimeInfo | undefined => {
|
||||
return runtimeInfos.find(r => r.id === toolId);
|
||||
};
|
||||
|
||||
const installedCatalogTools = catalog.filter(t => {
|
||||
const info = getRuntimeInfo(t.id);
|
||||
return info && info.status !== 'not_installed';
|
||||
});
|
||||
|
||||
const recommendedTools = catalog.filter(t => {
|
||||
const info = getRuntimeInfo(t.id);
|
||||
return !info || info.status === 'not_installed';
|
||||
});
|
||||
|
||||
// Only extra-detected tool IDs for batch describe (catalog tools already have descriptions)
|
||||
const extraToolIds = extraDetected.map(e => e.id);
|
||||
|
||||
const handleInstall = (tool: CliToolDefinition, method: string) => {
|
||||
setInstallTool({ tool, method });
|
||||
};
|
||||
|
||||
const handleInstallComplete = () => {
|
||||
setInstallTool(null);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleBatchDescribeComplete = (results: AutoDescCache) => {
|
||||
const merged = { ...autoDescriptions, ...results };
|
||||
setAutoDescriptions(merged);
|
||||
localStorage.setItem('cli-tools-auto-desc', JSON.stringify(merged));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<SpinnerGap size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 overflow-y-auto">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t('cliTools.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('cliTools.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Installed — catalog tools + extra system-detected tools merged */}
|
||||
{(installedCatalogTools.length > 0 || extraDetected.length > 0) && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{t('cliTools.installed')}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={() => setBatchDescribeOpen(true)}
|
||||
>
|
||||
<Sparkle size={14} />
|
||||
{t('cliTools.batchDescribe')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{/* Catalog tools that are installed */}
|
||||
{installedCatalogTools.map(tool => (
|
||||
<CliToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
runtimeInfo={getRuntimeInfo(tool.id)!}
|
||||
variant="installed"
|
||||
autoDescription={autoDescriptions[tool.id]}
|
||||
onDetail={() => setDetailTool({ tool, canInstall: false })}
|
||||
locale={locale}
|
||||
platform={platform}
|
||||
/>
|
||||
))}
|
||||
{/* Extra system-detected tools (not in catalog) */}
|
||||
{extraDetected.map(info => {
|
||||
const entry = EXTRA_WELL_KNOWN_BINS.find(([eid]) => eid === info.id);
|
||||
const displayName = entry?.[1] ?? info.id;
|
||||
const desc = autoDescriptions[info.id];
|
||||
return (
|
||||
<div
|
||||
key={info.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/40 px-3 py-2.5 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => setExtraDetailTool({ displayName, runtimeInfo: info })}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm truncate">{displayName}</h3>
|
||||
{info.version && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
v{info.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{desc
|
||||
? (locale === 'zh' ? desc.zh : desc.en)
|
||||
: t('cliTools.noDescription' as TranslationKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recommended (not installed) */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-3">{t('cliTools.recommended')}</h2>
|
||||
|
||||
{/* Brew not installed warning */}
|
||||
{!hasBrew && (platform === 'darwin' || platform === 'linux') && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 mb-3">
|
||||
<Warning size={16} className="text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">{t('cliTools.brewNotInstalled')}</p>
|
||||
<p>{t('cliTools.brewInstallGuide')}</p>
|
||||
<code className="block mt-1.5 bg-muted/50 rounded px-2 py-1 text-[11px] font-mono select-all">
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{recommendedTools.map(tool => (
|
||||
<CliToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
runtimeInfo={getRuntimeInfo(tool.id)}
|
||||
variant="recommended"
|
||||
onDetail={() => setDetailTool({ tool, canInstall: true })}
|
||||
onInstall={handleInstall}
|
||||
locale={locale}
|
||||
platform={platform}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{recommendedTools.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('cliTools.allInstalled')}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Docs link */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground pt-2">
|
||||
<ArrowSquareOut size={12} />
|
||||
<a
|
||||
href={locale === 'zh' ? 'https://www.codepilot.sh/zh/docs' : 'https://www.codepilot.sh/docs'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
{t('cliTools.viewDocs')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Detail dialog */}
|
||||
{detailTool && (
|
||||
<CliToolDetailDialog
|
||||
open={!!detailTool}
|
||||
onOpenChange={(open) => !open && setDetailTool(null)}
|
||||
tool={detailTool.tool}
|
||||
locale={locale}
|
||||
onInstall={detailTool.canInstall ? handleInstall : undefined}
|
||||
platform={platform}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Extra tool detail dialog */}
|
||||
{extraDetailTool && (
|
||||
<CliToolExtraDetailDialog
|
||||
open={!!extraDetailTool}
|
||||
onOpenChange={(open) => !open && setExtraDetailTool(null)}
|
||||
displayName={extraDetailTool.displayName}
|
||||
runtimeInfo={extraDetailTool.runtimeInfo}
|
||||
autoDescription={autoDescriptions[extraDetailTool.runtimeInfo.id]}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Install dialog */}
|
||||
{installTool && (
|
||||
<CliToolInstallDialog
|
||||
open={!!installTool}
|
||||
onOpenChange={(open) => !open && setInstallTool(null)}
|
||||
tool={installTool.tool}
|
||||
method={installTool.method}
|
||||
onComplete={handleInstallComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch AI describe dialog */}
|
||||
<CliToolBatchDescribeDialog
|
||||
open={batchDescribeOpen}
|
||||
onOpenChange={setBatchDescribeOpen}
|
||||
toolIds={extraToolIds}
|
||||
existingDescriptions={autoDescriptions}
|
||||
onComplete={handleBatchDescribeComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="hidden h-full shrink-0 flex-col overflow-hidden bg-sidebar lg:flex"
|
||||
className="hidden h-full shrink-0 flex-col overflow-hidden bg-sidebar/80 backdrop-blur-xl lg:flex"
|
||||
style={{ width: width ?? 240 }}
|
||||
>
|
||||
{/* Header - extra top padding for macOS traffic lights */}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Image,
|
||||
Gear,
|
||||
WifiHigh,
|
||||
Terminal,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ const navItems = [
|
||||
{ href: "/chat", label: "Chats", icon: ChatCircle },
|
||||
{ href: "/skills", label: "Skills", icon: Lightning },
|
||||
{ href: "/mcp", label: "MCP", icon: Plug },
|
||||
{ href: "/cli-tools", label: "CLI Tools", icon: Terminal },
|
||||
{ href: "/gallery", label: "Gallery", icon: Image },
|
||||
{ href: "/bridge", label: "Bridge", icon: WifiHigh },
|
||||
] as const;
|
||||
@@ -47,12 +49,13 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
'MCP': 'extensions.mcpServers',
|
||||
'Gallery': 'gallery.title',
|
||||
'Bridge': 'nav.bridge',
|
||||
'CLI Tools': 'nav.cliTools',
|
||||
};
|
||||
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
|
||||
const isSettingsActive = pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
|
||||
return (
|
||||
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar pb-3 pt-10">
|
||||
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar/80 backdrop-blur-xl pb-3 pt-10">
|
||||
{/* Nav icons */}
|
||||
<nav className="flex flex-1 flex-col items-center gap-1">
|
||||
{navItems.map((item) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-2xl border shadow-md transition-[color,box-shadow] outline-none",
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-2xl border shadow-sm transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
|
||||
Reference in new issue
Block a user