/** * @file AiToolsSettings.tsx * @description Settings → Agents → AI tools (`/settings/agents`): every AI coding CLI in the server's catalog * (GET /api/ai-tools). Installed ones are rows with an enable switch, a * Default badge or Set default, and Update whenever the catalog names an * updater — no version lookup, the updater itself knows whether it has work. * The ones available to install fold behind a disclosure, with Install where * the catalog knows how. Install/Update/Update all confirm the exact command * first, run as ONE server job at a time and show its log under the row * (polled from GET /api/ai-tools/job; a finished job folds to one line). * A right-click on an installed row opens its Update / Set default / * Enable-Disable (plus Copy path) as a menu; the buttons stay. * Refresh re-reads the login shell's PATH and re-detects. Icons come from * `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep * working. * @author Nguyễn Ngọc Trí Vĩ */ import { Switch } from "../Switch"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react"; import { useAiTools } from "../../hooks/useAiTools"; import { ConfirmModal } from "../ConfirmModal"; import { AiToolIcon } from "../AiToolIcon"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { api, type AiTool, type AiToolJob } from "../../lib/api"; type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" }; const GHOST_BTN = "inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50"; function Homepage({ tool }: { tool: AiTool }) { const { t } = useTranslation("settings"); if (!tool.homepageUrl) return null; return ( ); } function ToolSwitch({ tool, disabled, onToggle, }: { tool: AiTool; disabled: boolean; onToggle: () => void; }) { const { t } = useTranslation("settings"); return ( ); } function JobLog({ job }: { job: AiToolJob }) { const { t } = useTranslation("settings"); const ref = useRef(null); // A finished job folds to its status line; running or failed stays open. const [open, setOpen] = useState(job.status !== "ok"); useEffect(() => { if (job.status === "ok") setOpen(false); }, [job.status]); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [job.log, open]); const state = job.status === "running" ? t("aiTools.jobRunning") : job.status === "ok" ? t("aiTools.jobOk") : t("aiTools.jobFailed", { code: job.exitCode ?? "?" }); return (
{state} {job.status !== "running" && ( )}
{open && (
          {job.log}
        
)}
); } export function AiToolsSettings() { const { t } = useTranslation("settings"); const { tools, loaded, defaultId, refresh, update, reload } = useAiTools(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [job, setJob] = useState(null); const [confirm, setConfirm] = useState(null); const [showAvailable, setShowAvailable] = useState(false); const ctx = useContextMenu(); // A menu item: close the menu, then act on the tool it was opened on. const pick = (fn: (tool: AiTool) => unknown) => () => { const tool = ctx.menu?.item; ctx.close(); if (tool) fn(tool); }; const setDefault = (tool: AiTool) => void run(tool.id, () => update(tool.id, { default: true })); const toggle = (tool: AiTool) => void run(tool.id, () => update(tool.id, { enabled: !tool.enabled })); // A modal opened mid-job picks it up. Deferred like useAiTools' load: an // unreachable server (or a mocked api without it) means "no job". useEffect(() => { Promise.resolve() .then(() => api.aiTools.job()) .then(setJob) .catch(() => undefined); }, []); const running = job?.status === "running"; useEffect(() => { if (!running) return; const h = window.setInterval(async () => { try { const j = await api.aiTools.job(); setJob(j); if (j?.status !== "running") void reload(); } catch { /* next tick */ } }, 1000); return () => window.clearInterval(h); }, [running, reload]); const run = async (key: string, fn: () => Promise) => { setBusy(key); setError(null); try { await fn(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(null); } }; const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`; const commandFor = (tool: AiTool, action: "install" | "update"): string => { const inst = tool.install; if (action === "update" && tool.update?.kind === "self") { return [tool.command ?? tool.id, ...tool.update.args].join(" "); } if (inst?.kind === "npm") return npmCmd(inst.package); if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url }); return ""; }; const installed = tools.filter((x) => x.installed); const available = tools.filter((x) => !x.installed); // Mirrors the server's update-all: every installed tool with an updater. const updateAllTools = installed.filter((x) => x.update); const startConfirmed = async () => { const c = confirm; setConfirm(null); if (!c) return; await run("job", async () => { const r = c.kind === "install" ? await api.aiTools.install(c.tool.id) : c.kind === "update" ? await api.aiTools.updateTool(c.tool.id) : await api.aiTools.updateAll(); setJob(r.job); }); }; const jobBusy = running || busy !== null; const logFor = (id: string | null) => (job && job.tool === id ? : null); const confirmLines = confirm?.kind === "update-all" ? updateAllTools.map((x) => commandFor(x, "update")) : confirm ? [commandFor(confirm.tool, confirm.kind)] : []; return (

{t("aiTools.installed", { count: installed.length })}

{updateAllTools.length > 0 && ( )}
{error &&

{error}

} {logFor(null)} {loaded && installed.length === 0 && (

{t("aiTools.noneInstalled")}

)}
    {installed.map((tool) => { const isDefault = tool.id === defaultId; return (
  • ctx.open(e, tool)} className="px-3 py-2.5" >
    {tool.label} {isDefault && ( {t("aiTools.default")} )}
    {tool.command}
    {/* Fixed slots, so the buttons line up from row to row. */}
    {tool.update && ( )}
    {!isDefault && ( )}
    toggle(tool)} />
    {logFor(tool.id)}
  • ); })}
{ctx.menu && ( {ctx.menu.item.update && ( setConfirm({ kind: "update", tool }))} > {t("aiTools.update")} )} {ctx.menu.item.id !== defaultId && ( {t("aiTools.setDefault")} )} {ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")} {ctx.menu.item.path && ( <> void navigator.clipboard.writeText(tool.path ?? ""))} > {t("rowMenu.copyPath")} )} )}
{available.length > 0 && (
{showAvailable && (
    {available.map((tool) => (
  • {tool.label} {tool.command} {tool.install && ( )} {logFor(tool.id)}
  • ))}
)}
)} void startConfirmed()} onCancel={() => setConfirm(null)} >
          {confirmLines.join("\n")}
        
); }