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:
7418andClaude Opus 4.6 committed 2026-03-11 19:56:29 +08:00
1 parent 4e603323c4
commit 16edf9f59c
37 files changed
+3108 -78

No files matched your search

+415 -60
View File
@@ -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 -1
View File
@@ -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>
);
}
+119
View File
@@ -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 &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
</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>
);
}
+1 -1
View File
@@ -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 */}
+4 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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.