@meshxdata/fops 0.1.52 → 0.1.53

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 (86) hide show
  1. package/CHANGELOG.md +372 -0
  2. package/package.json +2 -6
  3. package/src/agent/agent.js +6 -0
  4. package/src/commands/setup.js +34 -0
  5. package/src/fleet-registry.js +38 -2
  6. package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
  7. package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
  8. package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
  9. package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
  10. package/src/plugins/api.js +4 -0
  11. package/src/plugins/builtins/docker-compose.js +59 -0
  12. package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +44 -53
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
  24. package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
  25. package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
  26. package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
  27. package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
  28. package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
  29. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
  30. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
  31. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
  32. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
  35. package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
  36. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
  37. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
  38. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
  39. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
  40. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
  41. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
  42. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
  43. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
  44. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
  45. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
  46. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
  47. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
  48. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
  49. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
  50. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
  51. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
  52. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
  53. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
  54. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
  55. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
  56. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
  57. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
  58. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
  59. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
  60. package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
  61. package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
  62. package/src/plugins/loader.js +34 -1
  63. package/src/plugins/registry.js +15 -0
  64. package/src/plugins/schemas.js +17 -0
  65. package/src/project.js +1 -1
  66. package/src/serve.js +196 -2
  67. package/src/shell.js +21 -1
  68. package/src/web/admin.html.js +236 -0
  69. package/src/web/api.js +73 -0
  70. package/src/web/dist/assets/index-BphVaAUd.css +1 -0
  71. package/src/web/dist/assets/index-CSckLzuG.js +129 -0
  72. package/src/web/dist/index.html +2 -2
  73. package/src/web/frontend/index.html +16 -0
  74. package/src/web/frontend/src/App.jsx +445 -0
  75. package/src/web/frontend/src/components/ChatView.jsx +910 -0
  76. package/src/web/frontend/src/components/InputBox.jsx +523 -0
  77. package/src/web/frontend/src/components/Sidebar.jsx +410 -0
  78. package/src/web/frontend/src/components/StatusBar.jsx +37 -0
  79. package/src/web/frontend/src/components/TabBar.jsx +87 -0
  80. package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
  81. package/src/web/frontend/src/index.css +78 -0
  82. package/src/web/frontend/src/main.jsx +6 -0
  83. package/src/web/frontend/vite.config.js +21 -0
  84. package/src/web/server.js +64 -1
  85. package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
  86. package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
@@ -0,0 +1,910 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ const MAX_RENDER_CONTENT_CHARS = 24_000;
3
+ const MESHX_ASCII = [
4
+ "███╗ ███╗███████╗███████╗██╗ ██╗██╗ ██╗",
5
+ "████╗ ████║██╔════╝██╔════╝██║ ██║╚██╗██╔╝",
6
+ "██╔████╔██║█████╗ ███████╗███████║ ╚███╔╝ ",
7
+ "██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║ ██╔██╗ ",
8
+ "██║ ╚═╝ ██║███████╗███████║██║ ██║██╔╝ ██╗",
9
+ "╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
10
+ ];
11
+
12
+ /* ===== Mermaid Diagram ===== */
13
+ let mermaidLoaded = false;
14
+ let mermaidLoadFailed = false;
15
+ let mermaidLoadPromise = null;
16
+
17
+ function loadMermaid() {
18
+ if (mermaidLoaded) return Promise.resolve();
19
+ if (mermaidLoadFailed) return Promise.reject(new Error("Mermaid CDN failed to load"));
20
+ if (mermaidLoadPromise) return mermaidLoadPromise;
21
+ mermaidLoadPromise = new Promise((resolve, reject) => {
22
+ const script = document.createElement("script");
23
+ script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
24
+ script.onload = () => {
25
+ window.mermaid.initialize({ startOnLoad: false, theme: "dark" });
26
+ mermaidLoaded = true;
27
+ resolve();
28
+ };
29
+ script.onerror = () => {
30
+ mermaidLoadFailed = true;
31
+ reject(new Error("Failed to load mermaid library from CDN"));
32
+ };
33
+ document.head.appendChild(script);
34
+ });
35
+ return mermaidLoadPromise;
36
+ }
37
+
38
+ function MermaidDiagram({ code }) {
39
+ const viewportRef = useRef(null);
40
+ const innerRef = useRef(null);
41
+ const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2, 9)}`);
42
+ const transformRef = useRef({ zoom: 1, panX: 0, panY: 0 });
43
+ const dragRef = useRef(null);
44
+ const [svg, setSvg] = useState(null);
45
+ const [error, setError] = useState(null);
46
+ const [showEditor, setShowEditor] = useState(false);
47
+ const [editCode, setEditCode] = useState(code);
48
+ const [copied, setCopied] = useState(false);
49
+
50
+ useEffect(() => { setEditCode(code); }, [code]);
51
+
52
+ async function renderCode(src) {
53
+ try {
54
+ await loadMermaid();
55
+ if (!window.mermaid) return null;
56
+ const { svg: rendered } = await window.mermaid.render(
57
+ `${idRef.current}-${Date.now()}`, src,
58
+ );
59
+ setError(null);
60
+ return rendered;
61
+ } catch (e) {
62
+ setError(e.message);
63
+ return null;
64
+ }
65
+ }
66
+
67
+ useEffect(() => {
68
+ let cancelled = false;
69
+ renderCode(code).then((s) => { if (!cancelled && s) setSvg(s); });
70
+ return () => { cancelled = true; };
71
+ }, [code]);
72
+
73
+ useEffect(() => {
74
+ if (!showEditor) return;
75
+ const t = setTimeout(() => {
76
+ renderCode(editCode).then((s) => { if (s) setSvg(s); });
77
+ }, 400);
78
+ return () => clearTimeout(t);
79
+ }, [editCode, showEditor]);
80
+
81
+ function applyTransform() {
82
+ const el = innerRef.current;
83
+ if (!el) return;
84
+ const { zoom, panX, panY } = transformRef.current;
85
+ el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
86
+ }
87
+
88
+ useEffect(() => {
89
+ const el = viewportRef.current;
90
+ if (!el) return;
91
+ const onWheel = (e) => {
92
+ e.preventDefault();
93
+ const rect = el.getBoundingClientRect();
94
+ const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
95
+ const t = transformRef.current;
96
+ const factor = e.deltaY > 0 ? 0.9 : 1.1;
97
+ const nz = Math.min(Math.max(t.zoom * factor, 0.1), 10);
98
+ t.panX = cx - (cx - t.panX) * (nz / t.zoom);
99
+ t.panY = cy - (cy - t.panY) * (nz / t.zoom);
100
+ t.zoom = nz;
101
+ applyTransform();
102
+ };
103
+ el.addEventListener("wheel", onWheel, { passive: false });
104
+ return () => el.removeEventListener("wheel", onWheel);
105
+ }, []);
106
+
107
+ useEffect(() => {
108
+ const onMove = (e) => {
109
+ const d = dragRef.current;
110
+ if (!d) return;
111
+ transformRef.current.panX = d.px + (e.clientX - d.x);
112
+ transformRef.current.panY = d.py + (e.clientY - d.y);
113
+ applyTransform();
114
+ };
115
+ const onUp = () => { dragRef.current = null; };
116
+ window.addEventListener("mousemove", onMove);
117
+ window.addEventListener("mouseup", onUp);
118
+ return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
119
+ }, []);
120
+
121
+ function onViewportMouseDown(e) {
122
+ if (e.button !== 0) return;
123
+ const t = transformRef.current;
124
+ dragRef.current = { x: e.clientX, y: e.clientY, px: t.panX, py: t.panY };
125
+ e.preventDefault();
126
+ }
127
+
128
+ function resetView() { transformRef.current = { zoom: 1, panX: 0, panY: 0 }; applyTransform(); }
129
+ function zoomBy(f) { transformRef.current.zoom = Math.min(Math.max(transformRef.current.zoom * f, 0.1), 10); applyTransform(); }
130
+
131
+ function handleCopy() {
132
+ navigator.clipboard?.writeText(showEditor ? editCode : code);
133
+ setCopied(true);
134
+ setTimeout(() => setCopied(false), 2000);
135
+ }
136
+
137
+ function openInMermaidLive() {
138
+ const src = showEditor ? editCode : code;
139
+ try {
140
+ const state = JSON.stringify({ code: src, mermaid: { theme: "dark" }, autoSync: true, updateDiagram: true });
141
+ window.open(`https://mermaid.live/edit#base64:${btoa(unescape(encodeURIComponent(state)))}`, "_blank");
142
+ } catch { window.open("https://mermaid.live", "_blank"); }
143
+ }
144
+
145
+ if (error && !svg) {
146
+ return (
147
+ <div className="my-3 rounded-xl overflow-hidden border border-red-500/20 bg-red-500/5 px-4 py-3 text-xs text-red-400 font-mono">
148
+ Mermaid error: {error}
149
+ <pre className="mt-2 text-[#4e4e63]">{code}</pre>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ if (!svg) {
155
+ return (
156
+ <div className="my-3 rounded-xl border border-[#1a1a28] bg-[#0a0a10] px-4 py-3 text-xs text-[#4e4e63] animate-pulse font-mono">
157
+ Rendering diagram...
158
+ </div>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div className="my-3 rounded-xl overflow-hidden border border-[#1a1a28] bg-[#0a0a10]">
164
+ <div className="flex items-center justify-between px-3 py-1.5 bg-[#0e0e16] border-b border-[#1a1a28]">
165
+ <span className="text-[9px] font-mono text-[#4e4e63] uppercase tracking-widest">mermaid</span>
166
+ <div className="flex items-center gap-0.5">
167
+ <button onClick={() => zoomBy(1 / 1.3)} className="px-1.5 py-0.5 text-[12px] text-[#3a3a50] hover:text-[#f97316] transition-colors" title="Zoom out">&minus;</button>
168
+ <button onClick={resetView} className="px-1.5 py-0.5 text-[10px] text-[#3a3a50] hover:text-[#f97316] transition-colors font-mono" title="Reset">1:1</button>
169
+ <button onClick={() => zoomBy(1.3)} className="px-1.5 py-0.5 text-[12px] text-[#3a3a50] hover:text-[#f97316] transition-colors" title="Zoom in">+</button>
170
+ <span className="w-px h-3 bg-[#1e1e2e] mx-1" />
171
+ <button onClick={handleCopy} className="px-1.5 py-0.5 text-[10px] font-mono text-[#3a3a50] hover:text-[#f97316] transition-colors">
172
+ {copied ? "copied" : "copy"}
173
+ </button>
174
+ <button onClick={() => { setShowEditor((v) => !v); }} className={`px-1.5 py-0.5 text-[10px] font-mono transition-colors ${showEditor ? "text-[#f97316]" : "text-[#3a3a50] hover:text-[#f97316]"}`}>
175
+ edit
176
+ </button>
177
+ <button onClick={openInMermaidLive} className="px-1.5 py-0.5 text-[10px] font-mono text-[#3a3a50] hover:text-[#f97316] transition-colors" title="Open in Mermaid Live">
178
+ &#8599;
179
+ </button>
180
+ </div>
181
+ </div>
182
+
183
+ <div className="flex" style={{ minHeight: showEditor ? 320 : undefined }}>
184
+ {showEditor && (
185
+ <div className="w-1/2 flex flex-col border-r border-[#1a1a28]" style={{ minHeight: 320 }}>
186
+ <textarea
187
+ className="flex-1 bg-transparent text-[12px] font-mono text-[#c0bfc6] p-3 resize-none focus:outline-none leading-relaxed"
188
+ value={editCode}
189
+ onChange={(e) => setEditCode(e.target.value)}
190
+ spellCheck={false}
191
+ />
192
+ {error && (
193
+ <div className="px-3 py-1.5 text-[10px] text-red-400 border-t border-[#1a1a28] font-mono truncate">{error}</div>
194
+ )}
195
+ </div>
196
+ )}
197
+ <div
198
+ ref={viewportRef}
199
+ className={`overflow-hidden cursor-grab active:cursor-grabbing ${showEditor ? "w-1/2" : "w-full"}`}
200
+ style={{ minHeight: showEditor ? 320 : 80 }}
201
+ onMouseDown={onViewportMouseDown}
202
+ >
203
+ <div ref={innerRef} className="p-4" style={{ transformOrigin: "0 0" }} dangerouslySetInnerHTML={{ __html: svg }} />
204
+ </div>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ /* ===== Markdown Renderer ===== */
211
+
212
+ function renderInline(text) {
213
+ if (!text) return "";
214
+
215
+ const parts = [];
216
+ let remaining = text;
217
+ let key = 0;
218
+
219
+ while (remaining.length > 0) {
220
+ // Bold
221
+ const boldMatch = remaining.match(/^(.*?)\*\*(.+?)\*\*/);
222
+ if (boldMatch && boldMatch[1].length < remaining.length) {
223
+ if (boldMatch[1]) parts.push(boldMatch[1]);
224
+ parts.push(<strong key={`b-${key++}`} className="font-semibold text-[#e0dfe4]">{boldMatch[2]}</strong>);
225
+ remaining = remaining.slice(boldMatch[0].length);
226
+ continue;
227
+ }
228
+
229
+ // Inline code
230
+ const codeMatch = remaining.match(/^(.*?)`([^`]+)`/);
231
+ if (codeMatch && codeMatch[1].length < remaining.length) {
232
+ if (codeMatch[1]) parts.push(codeMatch[1]);
233
+ parts.push(
234
+ <code key={`c-${key++}`} className="bg-[#f97316]/8 text-[#fb923c] px-1.5 py-0.5 rounded text-[11px] font-mono">
235
+ {codeMatch[2]}
236
+ </code>
237
+ );
238
+ remaining = remaining.slice(codeMatch[0].length);
239
+ continue;
240
+ }
241
+
242
+ // Link
243
+ const linkMatch = remaining.match(/^(.*?)\[([^\]]+)\]\(([^)]+)\)/);
244
+ if (linkMatch && linkMatch[1].length < remaining.length) {
245
+ if (linkMatch[1]) parts.push(linkMatch[1]);
246
+ parts.push(
247
+ <a key={`l-${key++}`} href={linkMatch[3]} target="_blank" rel="noopener"
248
+ className="text-[#f97316] underline decoration-[#f97316]/30 underline-offset-2 hover:text-[#fb923c] hover:decoration-[#fb923c]/50 transition-colors"
249
+ >
250
+ {linkMatch[2]}
251
+ </a>
252
+ );
253
+ remaining = remaining.slice(linkMatch[0].length);
254
+ continue;
255
+ }
256
+
257
+ parts.push(remaining);
258
+ break;
259
+ }
260
+
261
+ return parts;
262
+ }
263
+
264
+ function parseMemoryResultLine(line) {
265
+ const match = line.match(/^([a-f0-9]{8}):\s*(.+?)\s+\[(.+?)\]\s+\(score:\s*([^)]+)\)$/i);
266
+ if (!match) return null;
267
+ const [, id, summary, tagsRaw, score] = match;
268
+ const tags = tagsRaw.split(",").map((t) => t.trim()).filter(Boolean);
269
+ return { id, summary, tags, score: score.trim() };
270
+ }
271
+
272
+ function parseCommandLogBlock(text) {
273
+ if (!text || typeof text !== "string") return null;
274
+ const match = text.match(
275
+ /^Fetching logs\s*\n([^\n]+)\n\nCommand (succeeded|failed)\n([^\n]+)\n([\s\S]*)$/i,
276
+ );
277
+ if (!match) return null;
278
+ const [, requestedCommand, statusWord, executedCommand, rawOutput] = match;
279
+ return {
280
+ requestedCommand: requestedCommand.trim(),
281
+ executedCommand: executedCommand.trim(),
282
+ succeeded: String(statusWord).toLowerCase() === "succeeded",
283
+ output: String(rawOutput || "").trim(),
284
+ };
285
+ }
286
+
287
+ function normalizeLogOutput(output) {
288
+ if (!output) return "";
289
+ // If logs arrive as one wrapped line, split on repeated docker log prefixes.
290
+ return String(output)
291
+ .replace(/\s([a-z0-9][a-z0-9_.-]*-\d+\s+\|)/gi, "\n$1")
292
+ .replace(/\n{3,}/g, "\n\n")
293
+ .trim();
294
+ }
295
+
296
+ function CommandLogCard({ block }) {
297
+ const normalized = normalizeLogOutput(block.output);
298
+ const lines = normalized ? normalized.split("\n") : [];
299
+ const MAX_LOG_LINES = 80;
300
+ const truncated = lines.length > MAX_LOG_LINES;
301
+ const shownLines = truncated ? lines.slice(-MAX_LOG_LINES) : lines;
302
+
303
+ return (
304
+ <div className="my-3 rounded-xl overflow-hidden border border-[#1a1a28] bg-[#0a0a10]">
305
+ <div className="flex items-center justify-between px-4 py-2 bg-[#0e0e16] border-b border-[#1a1a28]">
306
+ <div className="flex items-center gap-2">
307
+ <span className="text-[9px] font-mono uppercase tracking-widest text-[#4e4e63]">command logs</span>
308
+ <span
309
+ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
310
+ block.succeeded
311
+ ? "text-emerald-300 border-emerald-400/35 bg-emerald-500/10"
312
+ : "text-red-300 border-red-400/35 bg-red-500/10"
313
+ }`}
314
+ >
315
+ {block.succeeded ? "success" : "failed"}
316
+ </span>
317
+ </div>
318
+ <span className="text-[10px] font-mono text-[#6b6b80]">{shownLines.length} lines</span>
319
+ </div>
320
+ <div className="px-4 py-2 border-b border-[#1a1a28]/70 text-[11px] font-mono text-[#8b8b9e]">
321
+ <span className="text-[#6b6b80] mr-2">cmd</span>
322
+ <span>{block.executedCommand || block.requestedCommand}</span>
323
+ </div>
324
+ <pre className="px-4 py-3 text-[12px] font-mono leading-relaxed overflow-x-auto text-[#c0bfc6]">
325
+ <code>{shownLines.join("\n") || "(no output)"}</code>
326
+ </pre>
327
+ {truncated && (
328
+ <div className="px-4 py-2 border-t border-[#1a1a28]/70 text-[11px] font-mono text-[#6b6b80]">
329
+ showing last {MAX_LOG_LINES} of {lines.length} lines
330
+ </div>
331
+ )}
332
+ </div>
333
+ );
334
+ }
335
+
336
+ function renderMarkdown(text) {
337
+ if (!text || typeof text !== "string") return text || "";
338
+
339
+ const lines = text.split("\n");
340
+ const elements = [];
341
+ let i = 0;
342
+
343
+ while (i < lines.length) {
344
+ const line = lines[i];
345
+
346
+ // Fenced code block
347
+ if (line.trimStart().startsWith("```")) {
348
+ const lang = line.trimStart().slice(3).trim();
349
+ const codeLines = [];
350
+ i++;
351
+ while (i < lines.length && !lines[i].trimStart().startsWith("```")) {
352
+ codeLines.push(lines[i]);
353
+ i++;
354
+ }
355
+ i++;
356
+
357
+ if (lang === "mermaid") {
358
+ elements.push(<MermaidDiagram key={`mermaid-${elements.length}`} code={codeLines.join("\n")} />);
359
+ continue;
360
+ }
361
+
362
+ elements.push(
363
+ <div key={`code-${elements.length}`} className="my-3 rounded-xl overflow-hidden border border-[#1a1a28] bg-[#0a0a10]">
364
+ <div className="flex items-center justify-between px-4 py-1.5 bg-[#0e0e16] border-b border-[#1a1a28]">
365
+ <span className="text-[9px] font-mono text-[#4e4e63] uppercase tracking-widest">{lang || "code"}</span>
366
+ <button
367
+ className="text-[10px] font-mono text-[#3a3a50] hover:text-[#f97316] transition-colors"
368
+ onClick={() => navigator.clipboard?.writeText(codeLines.join("\n"))}
369
+ >
370
+ copy
371
+ </button>
372
+ </div>
373
+ <pre className="px-4 py-3 text-[12px] font-mono overflow-x-auto leading-relaxed">
374
+ <code className="text-[#c0bfc6]">{codeLines.join("\n")}</code>
375
+ </pre>
376
+ </div>
377
+ );
378
+ continue;
379
+ }
380
+
381
+ // Horizontal rule
382
+ if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
383
+ elements.push(<hr key={`hr-${elements.length}`} className="border-[#1a1a28] my-3" />);
384
+ i++;
385
+ continue;
386
+ }
387
+
388
+ // Headers
389
+ const headerMatch = line.match(/^(#{1,3})\s+(.*)/);
390
+ if (headerMatch) {
391
+ const level = headerMatch[1].length;
392
+ const text = headerMatch[2];
393
+ const styles = [
394
+ "text-base font-bold text-[#f97316] mt-3 mb-1.5",
395
+ "text-sm font-bold text-[#818cf8] mt-2.5 mb-1",
396
+ "text-sm font-semibold text-[#fbbf24] mt-2 mb-1",
397
+ ];
398
+ elements.push(
399
+ <div key={`h-${elements.length}`} className={styles[level - 1]}>
400
+ {renderInline(text)}
401
+ </div>
402
+ );
403
+ i++;
404
+ continue;
405
+ }
406
+
407
+ // Unordered list
408
+ const ulMatch = line.match(/^(\s*)([-*])\s+(.*)/);
409
+ if (ulMatch) {
410
+ const indent = Math.floor(ulMatch[1].length / 2);
411
+ elements.push(
412
+ <div key={`ul-${elements.length}`} className="flex gap-2.5 text-sm" style={{ paddingLeft: `${(indent + 1) * 14}px` }}>
413
+ <span className="text-[#f97316] text-[10px] mt-1 shrink-0">&#x25CF;</span>
414
+ <span>{renderInline(ulMatch[3])}</span>
415
+ </div>
416
+ );
417
+ i++;
418
+ continue;
419
+ }
420
+
421
+ // Ordered list
422
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
423
+ if (olMatch) {
424
+ const indent = Math.floor(olMatch[1].length / 2);
425
+ elements.push(
426
+ <div key={`ol-${elements.length}`} className="flex gap-2.5 text-sm" style={{ paddingLeft: `${(indent + 1) * 14}px` }}>
427
+ <span className="text-[#f97316] text-xs font-mono shrink-0">{olMatch[2]}.</span>
428
+ <span>{renderInline(olMatch[3])}</span>
429
+ </div>
430
+ );
431
+ i++;
432
+ continue;
433
+ }
434
+
435
+ // Blockquote
436
+ const bqMatch = line.match(/^>\s?(.*)/);
437
+ if (bqMatch) {
438
+ elements.push(
439
+ <div key={`bq-${elements.length}`} className="border-l-2 border-[#818cf8]/30 pl-3 text-[#6e7a8a] italic text-sm">
440
+ {renderInline(bqMatch[1])}
441
+ </div>
442
+ );
443
+ i++;
444
+ continue;
445
+ }
446
+
447
+ // Table (pipe-delimited rows)
448
+ if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
449
+ const tableRows = [];
450
+ while (i < lines.length && lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|")) {
451
+ tableRows.push(lines[i]);
452
+ i++;
453
+ }
454
+ // Need at least header + separator + 1 data row
455
+ if (tableRows.length >= 3 && /^[\s|:*-]+$/.test(tableRows[1])) {
456
+ const parseCells = (row) =>
457
+ row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
458
+ const headers = parseCells(tableRows[0]);
459
+ const dataRows = tableRows.slice(2).map(parseCells);
460
+
461
+ elements.push(
462
+ <div key={`tbl-${elements.length}`} className="my-3 overflow-x-auto rounded-xl border border-[#1a1a28]">
463
+ <table className="w-full text-[12px] font-mono border-collapse">
464
+ <thead>
465
+ <tr className="bg-[#0e0e16]">
466
+ {headers.map((h, hi) => (
467
+ <th key={hi} className="text-left px-4 py-2 text-[10px] font-semibold text-[#4e4e63] uppercase tracking-widest border-b border-[#1a1a28]">
468
+ {renderInline(h)}
469
+ </th>
470
+ ))}
471
+ </tr>
472
+ </thead>
473
+ <tbody>
474
+ {dataRows.map((row, ri) => (
475
+ <tr key={ri} className={ri % 2 === 0 ? "bg-[#0a0a10]" : "bg-[#0c0c14]"}>
476
+ {row.map((cell, ci) => (
477
+ <td key={ci} className="px-4 py-1.5 text-[#8b8b9e] border-b border-[#1a1a28]/50">
478
+ {renderInline(cell)}
479
+ </td>
480
+ ))}
481
+ </tr>
482
+ ))}
483
+ </tbody>
484
+ </table>
485
+ </div>
486
+ );
487
+ } else {
488
+ // Not a valid table, render rows as paragraphs
489
+ for (const row of tableRows) {
490
+ elements.push(
491
+ <div key={`p-${elements.length}`} className="text-sm">
492
+ {renderInline(row)}
493
+ </div>
494
+ );
495
+ }
496
+ }
497
+ continue;
498
+ }
499
+
500
+ // Empty line
501
+ if (line.trim() === "") {
502
+ elements.push(<div key={`blank-${elements.length}`} className="h-2" />);
503
+ i++;
504
+ continue;
505
+ }
506
+
507
+ // Memory search block (tool output)
508
+ const trimmed = line.trim();
509
+ if ((trimmed === "✓" || trimmed === "✔") && lines[i + 1]?.trim().toLowerCase() === "memory") {
510
+ i += 2; // consume "✓" and "memory"
511
+ const items = [];
512
+ while (i < lines.length) {
513
+ const current = lines[i].trim();
514
+ if (!current) {
515
+ i++;
516
+ continue;
517
+ }
518
+ const parsed = parseMemoryResultLine(current);
519
+ if (!parsed) break;
520
+ items.push(parsed);
521
+ i++;
522
+ }
523
+
524
+ elements.push(
525
+ <div key={`memory-${elements.length}`} className="my-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5">
526
+ <div className="px-3 py-2 border-b border-emerald-500/20 text-[10px] font-mono uppercase tracking-widest text-emerald-300">
527
+ Memory
528
+ </div>
529
+ <div className="p-3 space-y-2">
530
+ {items.length > 0 ? (
531
+ items.map((item, idx) => (
532
+ <div key={`${item.id}-${idx}`} className="rounded-lg border border-[#1a1a28] bg-[#0a0a10] px-3 py-2">
533
+ <div className="flex items-center gap-2 mb-1">
534
+ <span className="text-[10px] font-mono text-[#fbbf24]">{item.id}</span>
535
+ <span className="text-[10px] text-[#4e4e63]">score {item.score}</span>
536
+ </div>
537
+ <div className="text-sm text-[#a0a0b0]">{item.summary}</div>
538
+ {item.tags.length > 0 && (
539
+ <div className="mt-1 text-[10px] text-[#4e4e63] font-mono">
540
+ {item.tags.map((t) => `#${t}`).join(" ")}
541
+ </div>
542
+ )}
543
+ </div>
544
+ ))
545
+ ) : (
546
+ <div className="text-sm text-[#8b8b9e]">No memory entries parsed.</div>
547
+ )}
548
+ </div>
549
+ </div>
550
+ );
551
+ continue;
552
+ }
553
+
554
+ // Regular paragraph
555
+ elements.push(
556
+ <div key={`p-${elements.length}`} className="text-sm">
557
+ {renderInline(line)}
558
+ </div>
559
+ );
560
+ i++;
561
+ }
562
+
563
+ return elements;
564
+ }
565
+
566
+ function parseAttachmentPreviews(content) {
567
+ const attachments = [];
568
+ let cleaned = String(content || "");
569
+
570
+ const fullBlockPattern =
571
+ /\[Attached file:\s*(.*?)\s*\|\s*type:\s*([^|]+?)\s*\|\s*size:\s*(\d+)\s*bytes\](?:\n\[Truncated[^\n]*\])?\n\n```[a-zA-Z0-9_-]*\n[\s\S]*?\n```/g;
572
+ cleaned = cleaned.replace(fullBlockPattern, (_m, name, type, size) => {
573
+ attachments.push({
574
+ name: String(name || "").trim(),
575
+ type: String(type || "").trim(),
576
+ sizeBytes: Number(size),
577
+ });
578
+ return "";
579
+ });
580
+
581
+ const markerPattern = /\[Attached file:\s*([^\]\n]+)\]/g;
582
+ cleaned = cleaned.replace(markerPattern, (_m, name) => {
583
+ attachments.push({ name: String(name || "").trim() });
584
+ return "";
585
+ });
586
+
587
+ const deduped = attachments.filter(
588
+ (item, idx) => attachments.findIndex((other) => other.name === item.name) === idx,
589
+ );
590
+
591
+ return {
592
+ cleanContent: cleaned.replace(/\n{3,}/g, "\n\n").trim(),
593
+ attachments: deduped,
594
+ };
595
+ }
596
+
597
+ /* ===== Message Components ===== */
598
+
599
+ function Message({ role, content }) {
600
+ const isUser = role === "user";
601
+ const [ttsBusy, setTtsBusy] = useState(false);
602
+ const [ttsError, setTtsError] = useState("");
603
+ const safeContent = typeof content === "string" && content.length > MAX_RENDER_CONTENT_CHARS
604
+ ? content.slice(0, MAX_RENDER_CONTENT_CHARS)
605
+ + `\n\n[message truncated to ${MAX_RENDER_CONTENT_CHARS} chars for UI stability]`
606
+ : content;
607
+ const { cleanContent, attachments } = parseAttachmentPreviews(safeContent);
608
+ const commandLogBlock = parseCommandLogBlock(typeof safeContent === "string" ? safeContent : "");
609
+
610
+ const speakWithBrowser = () => {
611
+ if (typeof window === "undefined" || !window.speechSynthesis) return false;
612
+ try {
613
+ window.speechSynthesis.cancel();
614
+ const utterance = new SpeechSynthesisUtterance(String(content || ""));
615
+ utterance.rate = 1;
616
+ utterance.pitch = 1;
617
+ window.speechSynthesis.speak(utterance);
618
+ return true;
619
+ } catch {
620
+ return false;
621
+ }
622
+ };
623
+
624
+ const handleSpeak = async () => {
625
+ if (isUser || !content || ttsBusy) return;
626
+ setTtsBusy(true);
627
+ setTtsError("");
628
+ try {
629
+ const res = await fetch("/api/tts", {
630
+ method: "POST",
631
+ headers: { "Content-Type": "application/json" },
632
+ body: JSON.stringify({ text: String(content) }),
633
+ });
634
+ if (!res.ok) {
635
+ if (speakWithBrowser()) return;
636
+ const body = await res.json().catch(() => ({}));
637
+ throw new Error(body?.error || `TTS failed (${res.status})`);
638
+ }
639
+ const blob = await res.blob();
640
+ const url = URL.createObjectURL(blob);
641
+ const audio = new Audio(url);
642
+ audio.onended = () => URL.revokeObjectURL(url);
643
+ audio.onerror = () => URL.revokeObjectURL(url);
644
+ await audio.play();
645
+ } catch (err) {
646
+ if (!speakWithBrowser()) {
647
+ setTtsError(err?.message || "Could not play TTS");
648
+ }
649
+ } finally {
650
+ setTtsBusy(false);
651
+ }
652
+ };
653
+
654
+ return (
655
+ <div className="animate-fade-in">
656
+ <div className="flex items-center gap-2 mb-1.5">
657
+ <div className={`text-[10px] font-semibold uppercase tracking-widest ${isUser ? "text-[#f97316]" : "text-[#818cf8]"}`}>
658
+ {isUser ? "You" : "Agent"}
659
+ </div>
660
+ {!isUser && (
661
+ <button
662
+ type="button"
663
+ onClick={handleSpeak}
664
+ disabled={ttsBusy}
665
+ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border transition-colors ${
666
+ ttsBusy
667
+ ? "text-[#4e4e63] border-[#26263a] cursor-not-allowed"
668
+ : "text-[#8b8b9e] border-[#26263a] hover:text-[#f97316] hover:border-[#f97316]/40"
669
+ }`}
670
+ title="Speak this response"
671
+ >
672
+ {ttsBusy ? "..." : "Speak"}
673
+ </button>
674
+ )}
675
+ </div>
676
+ <div className={`
677
+ text-sm leading-relaxed break-words pl-4 border-l-2
678
+ ${isUser ? "border-[#f97316]/20 text-[#c0bfc6]" : "border-[#818cf8]/20 text-[#a0a0b0]"}
679
+ `}>
680
+ {attachments.length > 0 && (
681
+ <div className="mb-2 flex flex-wrap gap-1.5">
682
+ {attachments.map((file) => (
683
+ <span
684
+ key={`attachment-${file.name}`}
685
+ className="inline-flex max-w-[260px] items-center gap-1.5 rounded-md border border-[#26263a] bg-[#0f0f16] px-2 py-1 text-[11px] text-[#b5b5c4]"
686
+ >
687
+ <span aria-hidden="true">📄</span>
688
+ <span className="truncate">{file.name}</span>
689
+ </span>
690
+ ))}
691
+ </div>
692
+ )}
693
+ {commandLogBlock
694
+ ? <CommandLogCard block={commandLogBlock} />
695
+ : (isUser ? cleanContent : renderMarkdown(cleanContent))}
696
+ </div>
697
+ {!isUser && ttsError && (
698
+ <div className="pl-4 mt-1 text-[11px] text-red-400">{ttsError}</div>
699
+ )}
700
+ </div>
701
+ );
702
+ }
703
+
704
+ function ToolCallDisplay({ name, status }) {
705
+ const icon = status === "running" ? "\u25C6" : status === "done" ? "\u2713" : "\u2717";
706
+ const styles = {
707
+ running: "text-[#fbbf24] bg-[#fbbf24]/8 border-[#fbbf24]/20",
708
+ done: "text-emerald-400 bg-emerald-400/8 border-emerald-400/20",
709
+ error: "text-red-400 bg-red-400/8 border-red-400/20",
710
+ };
711
+
712
+ return (
713
+ <div className="pl-4 animate-fade-in">
714
+ <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-mono border ${styles[status] || styles.running}`}>
715
+ <span>{icon}</span>
716
+ <span>{name}</span>
717
+ {status === "running" && <span className="animate-pulse">...</span>}
718
+ </span>
719
+ </div>
720
+ );
721
+ }
722
+
723
+ function ToolConfirmCard({ name, input, onRespond }) {
724
+ const isContinueReadBatching = name === "continue_read_batching";
725
+ const inputPreview = typeof input === "object" ? JSON.stringify(input, null, 2) : String(input || "");
726
+ const decisionModel = isContinueReadBatching
727
+ ? {
728
+ reason: String(input?.reason || "The agent made repeated read-only calls."),
729
+ suggestion: String(input?.suggestion || "Continue and batch remaining reads in parallel."),
730
+ continueAction: String(input?.continue_action || "yes"),
731
+ stopAction: String(input?.stop_action || "no"),
732
+ }
733
+ : null;
734
+
735
+ return (
736
+ <div className="mx-4 my-3 border border-[#fbbf24]/35 rounded-xl bg-[#18181f] p-4 animate-fade-in shadow-[0_6px_24px_rgba(0,0,0,0.35)]">
737
+ <div className="flex items-center gap-2 text-[#fbbf24] font-semibold text-xs mb-2">
738
+ <span>&#x26A0;</span>
739
+ <span>Approve: {name}</span>
740
+ </div>
741
+ {isContinueReadBatching ? (
742
+ <div className="mb-3 space-y-2 text-[12px]">
743
+ <div className="rounded-lg border border-[#26263a] bg-[#0a0a10] px-3 py-2">
744
+ <div className="text-[10px] uppercase tracking-widest text-[#6b6b80] mb-1">Reason</div>
745
+ <div className="text-[#c0bfc6]">{decisionModel.reason}</div>
746
+ </div>
747
+ <div className="rounded-lg border border-[#26263a] bg-[#0a0a10] px-3 py-2">
748
+ <div className="text-[10px] uppercase tracking-widest text-[#6b6b80] mb-1">Suggestion</div>
749
+ <div className="text-[#c0bfc6]">{decisionModel.suggestion}</div>
750
+ </div>
751
+ <div className="grid grid-cols-2 gap-2">
752
+ <div className="rounded-lg border border-[#26263a] bg-[#0a0a10] px-3 py-2">
753
+ <div className="text-[10px] uppercase tracking-widest text-[#6b6b80] mb-1">Continue Action</div>
754
+ <div className="text-emerald-300 font-mono">{decisionModel.continueAction}</div>
755
+ </div>
756
+ <div className="rounded-lg border border-[#26263a] bg-[#0a0a10] px-3 py-2">
757
+ <div className="text-[10px] uppercase tracking-widest text-[#6b6b80] mb-1">Stop Action</div>
758
+ <div className="text-red-300 font-mono">{decisionModel.stopAction}</div>
759
+ </div>
760
+ </div>
761
+ </div>
762
+ ) : (
763
+ inputPreview && (
764
+ <pre className="text-[11px] font-mono text-[#4e4e63] mb-3 max-h-20 overflow-hidden whitespace-pre-wrap break-all bg-[#0a0a10] rounded-lg px-3 py-2">
765
+ {inputPreview.slice(0, 300)}
766
+ </pre>
767
+ )
768
+ )}
769
+ <div className="flex gap-2">
770
+ <button
771
+ onClick={() => onRespond("yes")}
772
+ className="px-4 py-1.5 rounded-lg text-[11px] font-bold bg-[#ea580c] text-white border border-[#fdba74] hover:bg-[#f97316] transition-colors shadow-[0_0_0_1px_rgba(255,255,255,0.06)]"
773
+ >
774
+ {isContinueReadBatching ? "Continue" : "Approve"}
775
+ </button>
776
+ <button
777
+ onClick={() => onRespond("no")}
778
+ className="px-4 py-1.5 rounded-lg text-[11px] font-bold border border-red-400/50 text-red-300 bg-red-500/10 hover:bg-red-500/20 transition-colors"
779
+ >
780
+ {isContinueReadBatching ? "Stop" : "Deny"}
781
+ </button>
782
+ <button
783
+ onClick={() => onRespond("yes_all")}
784
+ className="px-4 py-1.5 rounded-lg text-[11px] font-bold border border-emerald-400/50 text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors"
785
+ >
786
+ {isContinueReadBatching ? "Auto-continue" : "Approve all"}
787
+ </button>
788
+ </div>
789
+ </div>
790
+ );
791
+ }
792
+
793
+ /* ===== Main ChatView ===== */
794
+
795
+ function ThinkingSpinner() {
796
+ return (
797
+ <span
798
+ className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-purple-400/25 border-t-purple-400"
799
+ aria-hidden="true"
800
+ />
801
+ );
802
+ }
803
+
804
+ export default function ChatView({ messages, streamingText, thinkingText, statusText, toolCalls, pendingConfirm, onRespondConfirm }) {
805
+ const scrollRef = useRef(null);
806
+ const shouldAutoScrollRef = useRef(true);
807
+ const bottomRef = useRef(null);
808
+
809
+ useEffect(() => {
810
+ if (!shouldAutoScrollRef.current) return;
811
+ bottomRef.current?.scrollIntoView({ behavior: "auto" });
812
+ }, [messages, streamingText, thinkingText]);
813
+
814
+ return (
815
+ <div
816
+ ref={scrollRef}
817
+ onWheel={(event) => {
818
+ if (event.deltaY < 0) {
819
+ // Upward scroll intent should immediately disable auto-follow.
820
+ shouldAutoScrollRef.current = false;
821
+ }
822
+ }}
823
+ onScroll={(event) => {
824
+ const el = event.currentTarget;
825
+ const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
826
+ shouldAutoScrollRef.current = distanceToBottom < 120;
827
+ }}
828
+ className="flex-1 overflow-y-auto px-6 py-5 space-y-5"
829
+ >
830
+ {/* Empty state */}
831
+ {messages.length === 0 && !streamingText && !statusText && (
832
+ <div className="flex items-center justify-center h-full select-none">
833
+ <div className="text-center">
834
+ <div className="relative mb-6">
835
+ <pre className="font-mono text-[9px] md:text-[10px] leading-[1.05] text-[#2a2a38] absolute inset-0 translate-x-[1px] translate-y-[1px] pointer-events-none">
836
+ {MESHX_ASCII.join("\n")}
837
+ </pre>
838
+ <pre className="font-mono text-[9px] md:text-[10px] leading-[1.05] text-[#7f7f95] relative">
839
+ {MESHX_ASCII.join("\n")}
840
+ </pre>
841
+ </div>
842
+ <div className="text-sm font-semibold text-[#2e2e40] mb-2 tracking-wide">meshx agent</div>
843
+ <div className="text-xs text-[#1e1e2a]">
844
+ Type a message or press{" "}
845
+ <kbd className="inline-block px-1.5 py-0.5 rounded bg-[#14141c] text-[#f97316] font-mono text-[10px] border border-[#1a1a28]">/</kbd>{" "}
846
+ for commands
847
+ </div>
848
+ <div className="mt-4 flex items-center justify-center gap-2 text-[10px] font-mono">
849
+ <span className="rounded border border-[#26263a] bg-[#0f0f16] px-2 py-1 text-[#8b8b9e]">/up</span>
850
+ <span className="rounded border border-[#26263a] bg-[#0f0f16] px-2 py-1 text-[#8b8b9e]">/bootstrap</span>
851
+ <span className="rounded border border-[#26263a] bg-[#0f0f16] px-2 py-1 text-[#8b8b9e]">/plan</span>
852
+ <span className="rounded border border-[#26263a] bg-[#0f0f16] px-2 py-1 text-[#8b8b9e]">/agents</span>
853
+ </div>
854
+ </div>
855
+ </div>
856
+ )}
857
+
858
+ {/* Messages */}
859
+ {messages.map((msg, i) => (
860
+ <Message key={i} role={msg.role} content={msg.content} />
861
+ ))}
862
+
863
+ {/* Tool calls */}
864
+ {toolCalls.map((tc, i) => (
865
+ <ToolCallDisplay key={`tool-${i}`} name={tc.name} status={tc.status} />
866
+ ))}
867
+
868
+ {/* Thinking */}
869
+ {thinkingText && (
870
+ <div className="pl-4 text-sm text-[#4e4e63] animate-fade-in border-l-2 border-purple-500/20">
871
+ <div className="flex items-center gap-2 mb-1">
872
+ <ThinkingSpinner />
873
+ <span className="text-[10px] font-semibold uppercase tracking-widest text-purple-300">Thinking</span>
874
+ </div>
875
+ <div>{thinkingText.split("\n").slice(-2).join("\n")}</div>
876
+ </div>
877
+ )}
878
+
879
+ {/* Streaming */}
880
+ {streamingText && (
881
+ <div className="animate-fade-in">
882
+ <div className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 text-[#818cf8]">Agent</div>
883
+ <div className="text-sm leading-relaxed break-words pl-4 text-[#8b8b9e] border-l-2 border-[#818cf8]/20">
884
+ {streamingText}
885
+ <span className="inline-block w-[2px] h-[14px] bg-[#818cf8] ml-0.5 align-middle animate-cursor" />
886
+ </div>
887
+ </div>
888
+ )}
889
+
890
+ {/* Tool confirmation */}
891
+ {pendingConfirm && (
892
+ <ToolConfirmCard
893
+ name={pendingConfirm.name}
894
+ input={pendingConfirm.input}
895
+ onRespond={onRespondConfirm}
896
+ />
897
+ )}
898
+
899
+ {/* Status (when not streaming) */}
900
+ {statusText && !streamingText && (
901
+ <div className="pl-4 text-sm text-[#f97316] animate-pulse">
902
+ <span className="inline-block w-1.5 h-1.5 rounded-full bg-[#f97316] mr-2 align-middle" />
903
+ {statusText}
904
+ </div>
905
+ )}
906
+
907
+ <div ref={bottomRef} />
908
+ </div>
909
+ );
910
+ }