@agentprojectcontext/apx 1.60.1 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/super-agent.js +7 -1
  3. package/src/core/agent/tools/handlers/_github.js +22 -0
  4. package/src/core/agent/tools/handlers/call-runtime.js +185 -76
  5. package/src/core/agent/tools/handlers/github-create-issue.js +30 -0
  6. package/src/core/agent/tools/handlers/github-list-repos.js +20 -0
  7. package/src/core/agent/tools/names.js +6 -0
  8. package/src/core/agent/tools/registry.js +4 -0
  9. package/src/core/channels/telegram/dispatch.js +24 -1
  10. package/src/core/channels/telegram/reply.js +79 -2
  11. package/src/core/integrations/catalog.js +17 -27
  12. package/src/core/integrations/plugins/asana.js +22 -0
  13. package/src/core/integrations/plugins/github.js +160 -0
  14. package/src/core/stores/runtime-callbacks.js +107 -0
  15. package/src/host/daemon/callback-reconciler.js +87 -0
  16. package/src/host/daemon/index.js +7 -0
  17. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +1 -0
  18. package/src/interfaces/web/dist/assets/index-CFcs16SV.js +778 -0
  19. package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +3 -3
  22. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +3 -6
  23. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +300 -0
  24. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +7 -8
  25. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +28 -26
  26. package/src/interfaces/web/src/i18n/en.ts +57 -0
  27. package/src/interfaces/web/src/i18n/es.ts +57 -0
  28. package/src/interfaces/web/src/lib/api/integrations.ts +28 -5
  29. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +28 -38
  30. package/src/interfaces/web/src/screens/project/McpsTab.tsx +113 -81
  31. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +0 -761
  32. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +0 -1
  33. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +0 -1
  34. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +0 -275
@@ -1,28 +1,27 @@
1
1
  import { useState } from "react";
2
2
  import useSWR from "swr";
3
- import { Network, Puzzle, Wrench } from "lucide-react";
3
+ import { Puzzle, Wrench } from "lucide-react";
4
4
  import { Integrations, type CatalogEntry, type IntegrationScope } from "../../lib/api";
5
5
  import { cn } from "../../lib/cn";
6
6
  import { Section } from "../../components/Section";
7
7
  import { Empty, Loading } from "../../components/ui";
8
- import { AsanaPlugin } from "../../components/integrations/AsanaPlugin";
8
+ import { PluginConnect } from "../../components/integrations/PluginConnect";
9
9
  import { ComingSoonPlugin } from "../../components/integrations/ComingSoonPlugin";
10
- import { McpsTab } from "./McpsTab";
10
+ import { t } from "../../i18n";
11
11
 
12
- type SubTab = "plugins" | "mcp" | "tools";
12
+ type SubTab = "plugins" | "tools";
13
13
 
14
- const SUBTABS: { value: SubTab; label: string; icon: typeof Puzzle }[] = [
15
- { value: "plugins", label: "Plugins", icon: Puzzle },
16
- { value: "mcp", label: "MCP Servers", icon: Network },
17
- { value: "tools", label: "Tools", icon: Wrench },
14
+ const SUBTABS: { value: SubTab; labelKey: "integrations.tab_plugins" | "integrations.tab_tools"; icon: typeof Puzzle }[] = [
15
+ { value: "plugins", labelKey: "integrations.tab_plugins", icon: Puzzle },
16
+ { value: "tools", labelKey: "integrations.tab_tools", icon: Wrench },
18
17
  ];
19
18
 
20
- // Renders the live plugin (Asana) or a coming-soon placeholder per catalog entry.
19
+ // Renders a connectable plugin (via the generic PluginConnect, driven by its
20
+ // `ui` descriptor) or a coming-soon placeholder. MCP servers are NOT shown here
21
+ // — they have their own top-level "MCPs" nav item.
21
22
  function PluginRow({ pid, scope, entry }: { pid: string; scope: IntegrationScope; entry: CatalogEntry }) {
22
- if (entry.coming_soon) return <ComingSoonPlugin entry={entry} />;
23
- if (entry.slug === "asana") return <AsanaPlugin pid={pid} scope={scope} />;
24
- // Implemented plugin without a bespoke UI yet — fall back to the placeholder.
25
- return <ComingSoonPlugin entry={entry} />;
23
+ if (entry.coming_soon || !entry.ui) return <ComingSoonPlugin entry={entry} />;
24
+ return <PluginConnect pid={pid} scope={scope} entry={entry} />;
26
25
  }
27
26
 
28
27
  function PluginsSection({ pid, scope }: { pid: string; scope: IntegrationScope }) {
@@ -30,16 +29,13 @@ function PluginsSection({ pid, scope }: { pid: string; scope: IntegrationScope }
30
29
 
31
30
  return (
32
31
  <div className="space-y-3">
33
- <p className="text-xs text-muted-foreground">
34
- Plugins de canal y servicio instalables por proyecto. Se guardan en el ámbito
35
- seleccionado arriba.
36
- </p>
32
+ <p className="text-xs text-muted-foreground">{t("integrations.plugins_hint")}</p>
37
33
  {isLoading && <Loading />}
38
34
  {(catalog || []).map((entry) => (
39
35
  <PluginRow key={entry.slug} pid={pid} scope={scope} entry={entry} />
40
36
  ))}
41
37
  <div className="rounded-xl border border-dashed border-border p-6 text-center">
42
- <p className="text-sm text-muted-foreground">Más plugins próximamente…</p>
38
+ <p className="text-sm text-muted-foreground">{t("integrations.more_soon")}</p>
43
39
  </div>
44
40
  </div>
45
41
  );
@@ -49,35 +45,33 @@ function ToolsSection({ pid }: { pid: string }) {
49
45
  const { data: catalog } = useSWR(`integrations-catalog-${pid}`, () => Integrations.catalog(pid));
50
46
  const rows = (catalog || [])
51
47
  .filter((c) => !c.coming_soon && (c.tools?.length ?? 0) > 0)
52
- .flatMap((c) => (c.tools || []).map((t) => ({ ...t, plugin: c.name, active: c.status.is_enabled })));
48
+ .flatMap((c) => (c.tools || []).map((tool) => ({ ...tool, plugin: c.name, active: c.status.is_enabled })));
53
49
 
54
50
  return (
55
51
  <div className="space-y-3">
56
- <p className="text-xs text-muted-foreground">
57
- Tools que los plugins conectados exponen a los agentes de este proyecto.
58
- </p>
52
+ <p className="text-xs text-muted-foreground">{t("integrations.tools_hint")}</p>
59
53
  {rows.length === 0 ? (
60
- <Empty>No hay tools de integraciones. Conectá un plugin para habilitarlas.</Empty>
54
+ <Empty>{t("integrations.tools_empty")}</Empty>
61
55
  ) : (
62
56
  <ul className="space-y-2">
63
- {rows.map((t) => (
64
- <li key={t.slug} className={cn("rounded-md border border-border bg-muted/30 px-3 py-2", !t.active && "opacity-55")}>
57
+ {rows.map((tool) => (
58
+ <li key={tool.slug} className={cn("rounded-md border border-border bg-muted/30 px-3 py-2", !tool.active && "opacity-55")}>
65
59
  <div className="flex items-center gap-2">
66
60
  <Wrench className="h-3.5 w-3.5 text-muted-foreground" />
67
- <span className="font-mono text-xs text-foreground">{t.slug}</span>
68
- <span className="ml-auto text-[10px] text-muted-foreground">{t.plugin}</span>
61
+ <span className="font-mono text-xs text-foreground">{tool.slug}</span>
62
+ <span className="ml-auto text-[10px] text-muted-foreground">{tool.plugin}</span>
69
63
  <span
70
64
  className={cn(
71
65
  "rounded border px-1.5 py-0.5 text-[10px]",
72
- t.active
66
+ tool.active
73
67
  ? "border-emerald-700/40 bg-emerald-900/20 text-emerald-400"
74
68
  : "border-border bg-muted text-muted-foreground",
75
69
  )}
76
70
  >
77
- {t.active ? "activo" : "inactivo"}
71
+ {tool.active ? t("integrations.tool_active") : t("integrations.tool_inactive")}
78
72
  </span>
79
73
  </div>
80
- <p className="mt-0.5 pl-5 text-[10px] text-muted-foreground">{t.desc}</p>
74
+ <p className="mt-0.5 pl-5 text-[10px] text-muted-foreground">{tool.desc}</p>
81
75
  </li>
82
76
  ))}
83
77
  </ul>
@@ -93,15 +87,12 @@ export function IntegrationsTab({ pid }: { pid: string }) {
93
87
  const [scope, setScope] = useState<IntegrationScope>(isBase ? "global" : "project");
94
88
 
95
89
  return (
96
- <Section
97
- title="Integrations"
98
- description="Plugins, MCP servers y tools disponibles para este proyecto"
99
- >
90
+ <Section title={t("integrations.title")} description={t("integrations.description")}>
100
91
  {/* Scope selector — a real project can use its own integrations or the
101
92
  global (default-project) ones. */}
102
93
  {!isBase && (
103
94
  <div className="mb-4 flex items-center gap-2">
104
- <span className="text-xs text-muted-foreground">Ámbito:</span>
95
+ <span className="text-xs text-muted-foreground">{t("integrations.scope_label")}</span>
105
96
  {(["project", "global"] as const).map((s) => (
106
97
  <button
107
98
  key={s}
@@ -113,7 +104,7 @@ export function IntegrationsTab({ pid }: { pid: string }) {
113
104
  : "border-border bg-muted/30 text-muted-foreground hover:bg-muted/50",
114
105
  )}
115
106
  >
116
- {s === "project" ? "Este proyecto" : "Global (default)"}
107
+ {s === "project" ? t("integrations.scope_project") : t("integrations.scope_global")}
117
108
  </button>
118
109
  ))}
119
110
  </div>
@@ -132,14 +123,13 @@ export function IntegrationsTab({ pid }: { pid: string }) {
132
123
  tab === s.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
133
124
  )}
134
125
  >
135
- <Icon className="h-3.5 w-3.5" /> {s.label}
126
+ <Icon className="h-3.5 w-3.5" /> {t(s.labelKey)}
136
127
  </button>
137
128
  );
138
129
  })}
139
130
  </div>
140
131
 
141
132
  {tab === "plugins" && <PluginsSection pid={pid} scope={scope} />}
142
- {tab === "mcp" && <McpsTab pid={pid} />}
143
133
  {tab === "tools" && <ToolsSection pid={pid} />}
144
134
  </Section>
145
135
  );
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import useSWR from "swr";
3
3
  import {
4
- CheckCircle2, FlaskConical, Pencil, Plus, ScrollText, Terminal, Trash2, X, XCircle,
4
+ ChevronDown, FlaskConical, Globe, Network, Pencil, Plus, ScrollText,
5
+ Terminal, Trash2, Wrench, X, XCircle,
5
6
  } from "lucide-react";
6
- import { Mcps, Vars, type McpAddBody, type McpScope, type McpTestResult, type McpLogsResult, type VarsList } from "../../lib/api";
7
+ import { Mcps, Vars, type McpAddBody, type McpScope, type McpLogsResult, type VarsList } from "../../lib/api";
7
8
  import type { McpEntry } from "../../types/daemon";
9
+ import { cn } from "../../lib/cn";
8
10
  import { Section } from "../../components/Section";
9
11
  import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch } from "../../components/ui";
10
12
  import { Tip } from "../../components/ui/tip";
@@ -19,6 +21,31 @@ type DialogMode =
19
21
  | { kind: "new" }
20
22
  | { kind: "edit"; entry: McpEntry };
21
23
 
24
+ // Per-row live test state: mirrors PandaProject's MCP cards — a colored dot +
25
+ // tool count come from the last on-demand test (tools/list) of each server.
26
+ type McpResult = { busy?: boolean; ok?: boolean; error?: string; tools?: { name: string; description: string }[] };
27
+
28
+ // Short one-liner describing how a server connects (http url or stdio command),
29
+ // shown under the name like Panda's config summary. Prefers an explicit
30
+ // description in the raw config when present.
31
+ function mcpSummary(m: McpEntry): string {
32
+ const raw = (m as unknown as { raw?: Record<string, unknown> }).raw || {};
33
+ if (typeof raw.description === "string" && raw.description.trim()) return raw.description.trim();
34
+ if (m.transport === "http" && m.url) return m.url.length > 64 ? m.url.slice(0, 61) + "…" : m.url;
35
+ const cmd = [m.command, ...(m.args || [])].filter(Boolean).join(" ");
36
+ if (cmd) return "$ " + (cmd.length > 64 ? cmd.slice(0, 61) + "…" : cmd);
37
+ return "";
38
+ }
39
+
40
+ // Colored status dot: red on a failed test, emerald when tested-ok or enabled,
41
+ // muted when disabled, amber while testing.
42
+ function dotClass(enabled: boolean, res?: McpResult): string {
43
+ if (res?.busy) return "bg-amber-400 animate-pulse";
44
+ if (res?.ok === false) return "bg-red-400";
45
+ if (res?.ok) return "bg-emerald-400";
46
+ return enabled ? "bg-emerald-500/70" : "bg-slate-500";
47
+ }
48
+
22
49
  const SOURCE_TONE: Record<string, "info" | "muted" | "success"> = {
23
50
  apc: "info",
24
51
  runtime: "success",
@@ -51,7 +78,8 @@ export function McpsTab({ pid }: { pid: string }) {
51
78
  const vars = useSWR<VarsList>(`/projects/${pid}/vars`, () => Vars.list(pid));
52
79
  const [dialog, setDialog] = useState<DialogMode>(null);
53
80
  const [activeMcp, setActiveMcp] = useState<string | null>(null);
54
- const [testFor, setTestFor] = useState<{ name: string; result?: McpTestResult; busy?: boolean } | null>(null);
81
+ const [results, setResults] = useState<Record<string, McpResult>>({});
82
+ const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
55
83
 
56
84
  const varNames = useMemo(
57
85
  () => (vars.data ? Object.keys(vars.data.effective).sort() : []),
@@ -75,12 +103,13 @@ export function McpsTab({ pid }: { pid: string }) {
75
103
 
76
104
  const runTest = async (name: string) => {
77
105
  setActiveMcp(name);
78
- setTestFor({ name, busy: true });
106
+ setResults((prev) => ({ ...prev, [name]: { busy: true } }));
107
+ setExpandedTools((prev) => ({ ...prev, [name]: true }));
79
108
  try {
80
109
  const result = await Mcps.test(pid, name);
81
- setTestFor({ name, result });
110
+ setResults((prev) => ({ ...prev, [name]: { ok: result.ok, error: result.error, tools: result.tools } }));
82
111
  } catch (e: any) {
83
- setTestFor({ name, result: { ok: false, error: e?.message || "error" } });
112
+ setResults((prev) => ({ ...prev, [name]: { ok: false, error: e?.message || "error" } }));
84
113
  }
85
114
  };
86
115
 
@@ -122,6 +151,12 @@ export function McpsTab({ pid }: { pid: string }) {
122
151
  const writable = m.source === "apc" || m.source === "runtime" || m.source === "global";
123
152
  const scopeForRemove: McpScope = sourceToScope(m.source);
124
153
  const isActive = activeMcp === m.name;
154
+ const enabled = m.enabled !== false;
155
+ const res = results[m.name];
156
+ const tools = res?.tools || [];
157
+ const open = !!expandedTools[m.name];
158
+ const summary = mcpSummary(m);
159
+ const Icon = m.transport === "http" ? Globe : Network;
125
160
  return (
126
161
  <li
127
162
  key={`${m.source}-${m.name}`}
@@ -132,45 +167,79 @@ export function McpsTab({ pid }: { pid: string }) {
132
167
  onClick={() => setActiveMcp(m.name)}
133
168
  role="button"
134
169
  >
135
- <div className="flex flex-wrap items-center gap-3">
136
- <span className="font-medium">{m.name}</span>
137
- <Badge tone={SOURCE_TONE[m.source] ?? "muted"} >{sourceLabel(m.source)}</Badge>
138
- <span className="ml-auto text-xs text-muted-fg">
139
- {(m.transport || "stdio").toUpperCase()}
140
- </span>
141
- <div onClick={(e) => e.stopPropagation()}>
142
- <Switch
143
- checked={m.enabled !== false}
144
- onChange={() => toggleEnabled(m)}
145
- label=""
146
- />
170
+ <div className="flex items-start gap-3">
171
+ {/* Icon + colored status dot */}
172
+ <div className="relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-background">
173
+ <Icon size={16} className="text-muted-fg" />
174
+ <span className={cn("absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-card", dotClass(enabled, res))} />
175
+ </div>
176
+
177
+ <div className="min-w-0 flex-1">
178
+ <div className="flex flex-wrap items-center gap-2">
179
+ <span className="font-medium">{m.name}</span>
180
+ <Badge tone={SOURCE_TONE[m.source] ?? "muted"}>{sourceLabel(m.source)}</Badge>
181
+ <span className="ml-auto text-xs text-muted-fg">{(m.transport || "stdio").toUpperCase()}</span>
182
+ <div onClick={(e) => e.stopPropagation()}>
183
+ <Switch checked={enabled} onChange={() => toggleEnabled(m)} label="" />
184
+ </div>
185
+ <Tip content={t("project.mcps.test_btn")}>
186
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")}>
187
+ {res?.busy ? <FlaskConical size={13} className="animate-pulse" /> : <FlaskConical size={13} />}
188
+ </Button>
189
+ </Tip>
190
+ <Tip content={t("project.mcps.logs_btn")}>
191
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")}>
192
+ <ScrollText size={13} />
193
+ </Button>
194
+ </Tip>
195
+ {writable && (
196
+ <Tip content={t("project.mcps.edit_btn")}>
197
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")}>
198
+ <Pencil size={13} />
199
+ </Button>
200
+ </Tip>
201
+ )}
202
+ {writable && (
203
+ <Button size="sm" variant="destructive" onClick={(e) => { e.stopPropagation(); remove(m.name, scopeForRemove); }}>
204
+ <Trash2 size={13} />
205
+ </Button>
206
+ )}
207
+ </div>
208
+
209
+ {summary && <p className="mt-0.5 truncate font-mono text-xs text-muted-fg">{summary}</p>}
210
+
211
+ {res?.ok === false && res.error && (
212
+ <p className="mt-1 flex items-start gap-1 text-xs text-red-400">
213
+ <XCircle size={12} className="mt-0.5 flex-shrink-0" /> <span className="break-words">{res.error}</span>
214
+ </p>
215
+ )}
216
+
217
+ {tools.length > 0 && (
218
+ <div className="mt-1.5" onClick={(e) => e.stopPropagation()}>
219
+ <button
220
+ type="button"
221
+ onClick={() => setExpandedTools((prev) => ({ ...prev, [m.name]: !prev[m.name] }))}
222
+ className="flex items-center gap-1 text-xs text-muted-fg transition-colors hover:text-fg"
223
+ >
224
+ <Wrench size={12} /> {t("project.mcps.tools_count", { n: tools.length })}
225
+ <ChevronDown size={12} className={cn("transition-transform", open && "rotate-180")} />
226
+ </button>
227
+ {open && (
228
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
229
+ {tools.slice(0, 40).map((tool) => (
230
+ <Tip key={tool.name} content={tool.description || "—"}>
231
+ <span className="inline-flex items-center gap-1 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-fg">
232
+ <Terminal size={10} /> {tool.name}
233
+ </span>
234
+ </Tip>
235
+ ))}
236
+ {tools.length > 40 && <span className="text-[10px] text-muted-fg">… +{tools.length - 40}</span>}
237
+ </div>
238
+ )}
239
+ </div>
240
+ )}
147
241
  </div>
148
- <Tip content={t("project.mcps.test_btn")}>
149
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")}>
150
- <FlaskConical size={13} />
151
- </Button>
152
- </Tip>
153
- <Tip content={t("project.mcps.logs_btn")}>
154
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")}>
155
- <ScrollText size={13} />
156
- </Button>
157
- </Tip>
158
- {writable && (
159
- <Tip content={t("project.mcps.edit_btn")}>
160
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")}>
161
- <Pencil size={13} />
162
- </Button>
163
- </Tip>
164
- )}
165
- {writable && (
166
- <Button size="sm" variant="destructive" onClick={(e) => { e.stopPropagation(); remove(m.name, scopeForRemove); }}>
167
- <Trash2 size={13} />
168
- </Button>
169
- )}
170
242
  </div>
171
- {testFor?.name === m.name && (
172
- <TestResultRow result={testFor.result} busy={!!testFor.busy} onClose={() => setTestFor(null)} />
173
- )}
174
243
  </li>
175
244
  );
176
245
  })}
@@ -190,49 +259,12 @@ export function McpsTab({ pid }: { pid: string }) {
190
259
  </div>
191
260
 
192
261
  <div className="lg:col-span-1">
193
- <LogsPanel pid={pid} mcpName={activeMcp} runningTest={!!testFor?.busy} />
262
+ <LogsPanel pid={pid} mcpName={activeMcp} runningTest={!!(activeMcp && results[activeMcp]?.busy)} />
194
263
  </div>
195
264
  </div>
196
265
  );
197
266
  }
198
267
 
199
- function TestResultRow({
200
- result,
201
- busy,
202
- onClose,
203
- }: { result?: McpTestResult; busy: boolean; onClose: () => void }) {
204
- return (
205
- <div className="mt-2 rounded border border-border/60 bg-background/40 p-2 text-xs">
206
- <div className="mb-1 flex items-center gap-2">
207
- {busy && <span className="text-muted-fg">{t("project.mcps.testing")}</span>}
208
- {!busy && result?.ok && (
209
- <span className="flex items-center gap-1 text-emerald-400">
210
- <CheckCircle2 size={12} /> {t("project.mcps.test_ok", { n: String(result.tool_count ?? 0) })}
211
- </span>
212
- )}
213
- {!busy && result && !result.ok && (
214
- <span className="flex items-center gap-1 text-red-400">
215
- <XCircle size={12} /> {result.error}
216
- </span>
217
- )}
218
- <button className="ml-auto text-muted-fg hover:text-fg" onClick={onClose}>×</button>
219
- </div>
220
- {!busy && result?.ok && result.tools && result.tools.length > 0 && (
221
- <ul className="space-y-0.5 font-mono">
222
- {result.tools.slice(0, 10).map((tool) => (
223
- <li key={tool.name} className="truncate">
224
- <span className="text-primary">{tool.name}</span>
225
- {tool.description && <span className="ml-2 text-muted-fg">— {tool.description}</span>}
226
- </li>
227
- ))}
228
- {result.tools.length > 10 && (
229
- <li className="text-muted-fg">… +{result.tools.length - 10}</li>
230
- )}
231
- </ul>
232
- )}
233
- </div>
234
- );
235
- }
236
268
 
237
269
  // Right-side terminal-style live logs panel. Polls the daemon every 1.5s
238
270
  // while a test is running (or every 4s when idle and an MCP is pinned) so