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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user