@hoox-sh/hoox-tui 0.2.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 (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ import { useState, useEffect, useMemo } from "react";
8
+ import {
9
+ Colors,
10
+ getHooxRepoPath,
11
+ resolveHooxRuntimeRoot,
12
+ useUIStore,
13
+ } from "@hoox-sh/hoox-shared";
14
+ import { useKeyboard } from "@opentui/react";
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ import { ErrorBoundary } from "../shared/error-boundary";
18
+ import { ViewHeader } from "../shared/view-header";
19
+ import { EmptyState } from "../shared/spinner";
20
+
21
+ /** Cap default flow list so huge graphs stay responsive. */
22
+ export const MAX_FLOWS_PREVIEW = 15;
23
+
24
+ // Define types for the graph metadata
25
+ interface GraphMetadata {
26
+ workers: Record<
27
+ string,
28
+ {
29
+ description: string;
30
+ category: string;
31
+ tags: string[];
32
+ isPublic: boolean;
33
+ smartPlacement: boolean;
34
+ }
35
+ >;
36
+ infrastructure: Record<
37
+ string,
38
+ {
39
+ label: string;
40
+ category: string;
41
+ description: string;
42
+ tags: string[];
43
+ bindingName: string;
44
+ }
45
+ >;
46
+ dataFlows: Array<{
47
+ source: string;
48
+ target: string;
49
+ description: string;
50
+ flowType: string;
51
+ }>;
52
+ communities: Array<{
53
+ id: string;
54
+ label: string;
55
+ description: string;
56
+ nodeIds: string[];
57
+ }>;
58
+ }
59
+
60
+ /**
61
+ * Resolve graph-metadata.json regardless of launch CWD.
62
+ * Candidates: runtime root (HOOX_REPO / cwd / ~/.hoox/repo), walk-up from
63
+ * cwd, and relative to this source file (packages/tui/… → monorepo root).
64
+ */
65
+ /**
66
+ * Resolve path to graph-metadata.json.
67
+ *
68
+ * Override with `HOOX_GRAPH_METADATA_PATH` (absolute or relative) for tests
69
+ * and non-standard layouts — avoids process-wide `fs` mocks in the suite.
70
+ */
71
+ export function resolveGraphMetadataPath(): string | null {
72
+ const override = process.env.HOOX_GRAPH_METADATA_PATH?.trim();
73
+ if (override) {
74
+ const resolved = path.isAbsolute(override)
75
+ ? override
76
+ : path.resolve(process.cwd(), override);
77
+ return fs.existsSync(resolved) ? resolved : null;
78
+ }
79
+
80
+ const fileName = "graph-metadata.json";
81
+ const candidates: string[] = [
82
+ path.resolve(process.cwd(), fileName),
83
+ // This file lives at packages/tui/src/components/views/
84
+ path.resolve(import.meta.dir, "../../../../../", fileName),
85
+ ];
86
+
87
+ const runtime = resolveHooxRuntimeRoot();
88
+ if (runtime.root) {
89
+ candidates.push(path.join(runtime.root, fileName));
90
+ }
91
+ candidates.push(path.join(getHooxRepoPath(), fileName));
92
+
93
+ // Walk up from CWD looking for the monorepo marker
94
+ let dir = process.cwd();
95
+ for (let i = 0; i < 8; i++) {
96
+ candidates.push(path.join(dir, fileName));
97
+ const parent = path.dirname(dir);
98
+ if (parent === dir) break;
99
+ dir = parent;
100
+ }
101
+
102
+ for (const candidate of candidates) {
103
+ if (fs.existsSync(candidate)) return candidate;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function EdgeTopology() {
109
+ return (
110
+ <ErrorBoundary viewName="Edge Topology">
111
+ <EdgeTopologyInner />
112
+ </ErrorBoundary>
113
+ );
114
+ }
115
+
116
+ function EdgeTopologyInner() {
117
+ const activeView = useUIStore((s) => s.activeView);
118
+ const isActive = activeView === "edge-topology";
119
+
120
+ const [metadata, setMetadata] = useState<GraphMetadata | null>(null);
121
+ const [error, setError] = useState<string | null>(null);
122
+ const [selectedNode, setSelectedNode] = useState<string | null>(null);
123
+ const [activeCommunity, setActiveCommunity] = useState<string | null>(null);
124
+ const [focusedIndex, setFocusedIndex] = useState<number>(0);
125
+
126
+ useEffect(() => {
127
+ let cancelled = false;
128
+ try {
129
+ const graphPath = resolveGraphMetadataPath();
130
+ if (!graphPath) {
131
+ if (!cancelled) {
132
+ setError(
133
+ "graph-metadata.json not found. Run `bun run graph` from the monorepo root, set HOOX_REPO, or `hoox doctor --fix-runtime`."
134
+ );
135
+ }
136
+ return;
137
+ }
138
+ const data = fs.readFileSync(graphPath, "utf-8");
139
+ if (cancelled) return;
140
+ setMetadata(JSON.parse(data) as GraphMetadata);
141
+ } catch (err) {
142
+ if (!cancelled) {
143
+ setError(err instanceof Error ? err.message : String(err));
144
+ }
145
+ }
146
+ return () => {
147
+ cancelled = true;
148
+ };
149
+ }, []);
150
+
151
+ const workers = metadata ? Object.entries(metadata.workers) : [];
152
+ const infra = metadata ? Object.entries(metadata.infrastructure) : [];
153
+ const communities = metadata ? metadata.communities : [];
154
+
155
+ // Combine all selectable items for keyboard navigation
156
+ const selectableItems = useMemo(() => {
157
+ if (!metadata)
158
+ return [] as Array<{
159
+ type: string;
160
+ id: string | null;
161
+ label?: string;
162
+ }>;
163
+
164
+ // Filter nodes if a community is selected
165
+ const visibleWorkers = activeCommunity
166
+ ? workers.filter(([id]) =>
167
+ communities
168
+ .find((c) => c.id === activeCommunity)
169
+ ?.nodeIds.includes(`workspace:${id}`)
170
+ )
171
+ : workers;
172
+
173
+ const visibleInfra = activeCommunity
174
+ ? infra.filter(([id]) =>
175
+ communities
176
+ .find((c) => c.id === activeCommunity)
177
+ ?.nodeIds.includes(`infra:${id}`)
178
+ )
179
+ : infra;
180
+
181
+ return [
182
+ { type: "community", id: null, label: "All" },
183
+ ...communities.map((c) => ({
184
+ type: "community",
185
+ id: c.id,
186
+ label: c.label,
187
+ })),
188
+ ...visibleWorkers.map(([id]) => ({ type: "worker", id })),
189
+ ...visibleInfra.map(([id]) => ({ type: "infra", id })),
190
+ ];
191
+ }, [metadata, activeCommunity, workers, infra, communities]);
192
+
193
+ // Clamp focus when the selectable set shrinks (community filter)
194
+ useEffect(() => {
195
+ if (selectableItems.length === 0) {
196
+ setFocusedIndex(0);
197
+ return;
198
+ }
199
+ setFocusedIndex((prev) =>
200
+ Math.min(prev, Math.max(0, selectableItems.length - 1))
201
+ );
202
+ }, [selectableItems.length]);
203
+
204
+ useKeyboard((key) => {
205
+ if (!isActive) return;
206
+ if (key.name === "escape") {
207
+ setSelectedNode(null);
208
+ setActiveCommunity(null);
209
+ setFocusedIndex(0);
210
+ return;
211
+ }
212
+
213
+ const n = selectableItems.length;
214
+ if (n === 0) return;
215
+
216
+ if (key.name === "right" || key.name === "tab") {
217
+ setFocusedIndex((prev) => (prev + 1) % n);
218
+ return;
219
+ }
220
+
221
+ if (key.name === "left" || (key.name === "tab" && key.shift)) {
222
+ setFocusedIndex((prev) => (prev - 1 + n) % n);
223
+ return;
224
+ }
225
+
226
+ if (key.name === "return" || key.name === "space") {
227
+ const item = selectableItems[focusedIndex];
228
+ if (!item) return;
229
+
230
+ if (item.type === "community") {
231
+ setActiveCommunity(item.id);
232
+ setSelectedNode(null);
233
+ setFocusedIndex(0); // Reset focus when community changes
234
+ } else if (item.id) {
235
+ setSelectedNode(item.id);
236
+ }
237
+ }
238
+ });
239
+
240
+ if (error) {
241
+ return (
242
+ <box flexDirection="column" padding={1} flexGrow={1}>
243
+ <ViewHeader title="EDGE TOPOLOGY" showDivider={false} />
244
+ <EmptyState
245
+ message="Error loading topology data:"
246
+ suggestion={error}
247
+ icon="⚠"
248
+ />
249
+ </box>
250
+ );
251
+ }
252
+
253
+ if (!metadata) {
254
+ return (
255
+ <box
256
+ flexDirection="column"
257
+ padding={1}
258
+ justifyContent="center"
259
+ alignItems="center"
260
+ height="100%"
261
+ >
262
+ <text fg={Colors.muted}>Loading Edge Topology...</text>
263
+ </box>
264
+ );
265
+ }
266
+
267
+ // Determine what to show in the right column
268
+ // (metadata is non-null here: the guard at the top of this branch returns early)
269
+ let rightColumnContent;
270
+ if (selectedNode) {
271
+ const worker = metadata.workers[selectedNode];
272
+ const infraNode = metadata.infrastructure[selectedNode];
273
+
274
+ if (worker) {
275
+ const inboundFlows = metadata.dataFlows.filter(
276
+ (f) => f.target === selectedNode
277
+ );
278
+ const outboundFlows = metadata.dataFlows.filter(
279
+ (f) => f.source === selectedNode
280
+ );
281
+
282
+ rightColumnContent = (
283
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
284
+ <text bold fg={Colors.success} marginBottom={1}>
285
+ {selectedNode.replace("workers/", "")}
286
+ </text>
287
+ <text fg={Colors.foreground} marginBottom={1}>
288
+ {worker.description}
289
+ </text>
290
+
291
+ <text bold fg={Colors.muted} marginTop={1}>
292
+ TAGS
293
+ </text>
294
+ <box flexDirection="row" flexWrap="wrap" marginBottom={1}>
295
+ {(worker.tags ?? []).map((t) => (
296
+ <text key={t} fg={Colors.info} marginRight={1}>
297
+ #{t}
298
+ </text>
299
+ ))}
300
+ </box>
301
+
302
+ <text bold fg={Colors.muted} marginTop={1}>
303
+ {`INBOUND FLOWS (${inboundFlows.length})`}
304
+ </text>
305
+ {inboundFlows.slice(0, MAX_FLOWS_PREVIEW).map((f, i) => (
306
+ <text key={i} fg={Colors.foreground} dim>
307
+ ← {f.source.replace("workers/", "")}: {f.flowType}
308
+ </text>
309
+ ))}
310
+ {inboundFlows.length > MAX_FLOWS_PREVIEW && (
311
+ <text dim fg={Colors.muted}>
312
+ … +{inboundFlows.length - MAX_FLOWS_PREVIEW} more
313
+ </text>
314
+ )}
315
+
316
+ <text bold fg={Colors.muted} marginTop={1}>
317
+ {`OUTBOUND FLOWS (${outboundFlows.length})`}
318
+ </text>
319
+ {outboundFlows.slice(0, MAX_FLOWS_PREVIEW).map((f, i) => (
320
+ <text key={i} fg={Colors.foreground} dim>
321
+ → {f.target.replace("workers/", "")}: {f.flowType}
322
+ </text>
323
+ ))}
324
+ {outboundFlows.length > MAX_FLOWS_PREVIEW && (
325
+ <text dim fg={Colors.muted}>
326
+ … +{outboundFlows.length - MAX_FLOWS_PREVIEW} more
327
+ </text>
328
+ )}
329
+ </box>
330
+ );
331
+ } else if (infraNode) {
332
+ rightColumnContent = (
333
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
334
+ <text bold fg={Colors.info} marginBottom={1}>
335
+ {infraNode.label}
336
+ </text>
337
+ <text fg={Colors.foreground} marginBottom={1}>
338
+ {infraNode.description}
339
+ </text>
340
+
341
+ <text bold fg={Colors.muted} marginTop={1}>
342
+ BINDING
343
+ </text>
344
+ <text fg={Colors.accent} marginBottom={1}>
345
+ {infraNode.bindingName}
346
+ </text>
347
+
348
+ <text bold fg={Colors.muted} marginTop={1}>
349
+ TAGS
350
+ </text>
351
+ <box flexDirection="row" flexWrap="wrap" marginBottom={1}>
352
+ {(infraNode.tags ?? []).map((t) => (
353
+ <text key={t} fg={Colors.info} marginRight={1}>
354
+ #{t}
355
+ </text>
356
+ ))}
357
+ </box>
358
+ </box>
359
+ );
360
+ } else {
361
+ rightColumnContent = (
362
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
363
+ <text fg={Colors.warning}>
364
+ Node not found in metadata: {selectedNode}
365
+ </text>
366
+ </box>
367
+ );
368
+ }
369
+ } else {
370
+ // Default flows view
371
+ rightColumnContent = (
372
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
373
+ {metadata.dataFlows.length === 0 ? (
374
+ <EmptyState message="No data flows in graph metadata." icon="↔" />
375
+ ) : (
376
+ <>
377
+ {metadata.dataFlows.slice(0, MAX_FLOWS_PREVIEW).map((flow, idx) => (
378
+ <box key={idx} flexDirection="column" marginBottom={1}>
379
+ <box flexDirection="row">
380
+ <text fg={Colors.success}>
381
+ {flow.source.replace("workers/", "")}
382
+ </text>
383
+ <text fg={Colors.muted} marginX={1}>
384
+ →
385
+ </text>
386
+ <text fg={Colors.info}>
387
+ {flow.target.replace("workers/", "")}
388
+ </text>
389
+ </box>
390
+ <text dim fg={Colors.muted}>
391
+ {flow.flowType}
392
+ </text>
393
+ </box>
394
+ ))}
395
+ {metadata.dataFlows.length > MAX_FLOWS_PREVIEW && (
396
+ <text dim fg={Colors.muted}>
397
+ ... and {metadata.dataFlows.length - MAX_FLOWS_PREVIEW} more
398
+ flows
399
+ </text>
400
+ )}
401
+ </>
402
+ )}
403
+ </box>
404
+ );
405
+ }
406
+
407
+ return (
408
+ <box flexDirection="column" width="100%" height="100%">
409
+ <ViewHeader
410
+ title="EDGE TOPOLOGY"
411
+ meta={
412
+ <text fg={Colors.muted} dim>
413
+ {`${workers.length} Workers • ${infra.length} Infrastructure Nodes • ${metadata.dataFlows.length} Data Flows`}
414
+ </text>
415
+ }
416
+ />
417
+
418
+ {/* Main Content */}
419
+ <box flexDirection="row" flexGrow={1} paddingTop={1}>
420
+ {/* Left Column: Communities & Nodes */}
421
+ <box
422
+ flexDirection="column"
423
+ width="60%"
424
+ paddingRight={2}
425
+ borderRight
426
+ borderStyle="single"
427
+ borderColor={Colors.border}
428
+ >
429
+ {/* Communities Filter */}
430
+ <box flexDirection="row" marginBottom={1} flexWrap="wrap">
431
+ <text fg={Colors.muted} marginRight={1}>
432
+ View:
433
+ </text>
434
+ <text
435
+ fg={activeCommunity === null ? Colors.accent : Colors.text}
436
+ bg={
437
+ selectableItems[focusedIndex]?.id === null &&
438
+ selectableItems[focusedIndex]?.type === "community"
439
+ ? Colors.muted
440
+ : undefined
441
+ }
442
+ bold={activeCommunity === null}
443
+ marginRight={2}
444
+ >
445
+ [All]
446
+ </text>
447
+ {communities.map((c) => (
448
+ <text
449
+ key={c.id}
450
+ fg={activeCommunity === c.id ? Colors.accent : Colors.text}
451
+ bg={
452
+ selectableItems[focusedIndex]?.id === c.id &&
453
+ selectableItems[focusedIndex]?.type === "community"
454
+ ? Colors.muted
455
+ : undefined
456
+ }
457
+ bold={activeCommunity === c.id}
458
+ marginRight={2}
459
+ >
460
+ [{c.label}]
461
+ </text>
462
+ ))}
463
+ </box>
464
+
465
+ {/* Workers Grid */}
466
+ <text bold fg={Colors.success} marginBottom={1}>
467
+ WORKERS
468
+ </text>
469
+ <box flexDirection="row" flexWrap="wrap" marginBottom={2}>
470
+ {(activeCommunity
471
+ ? workers.filter(([id]) =>
472
+ communities
473
+ .find((c) => c.id === activeCommunity)
474
+ ?.nodeIds.includes(`workspace:${id}`)
475
+ )
476
+ : workers
477
+ ).map(([id, w]) => {
478
+ const isFocused =
479
+ selectableItems[focusedIndex]?.id === id &&
480
+ selectableItems[focusedIndex]?.type === "worker";
481
+ return (
482
+ <box
483
+ key={id}
484
+ width="30%"
485
+ marginRight={1}
486
+ marginBottom={1}
487
+ padding={1}
488
+ borderStyle="rounded"
489
+ borderColor={
490
+ selectedNode === id
491
+ ? Colors.accent
492
+ : isFocused
493
+ ? Colors.foreground
494
+ : Colors.border
495
+ }
496
+ flexDirection="column"
497
+ >
498
+ <text bold fg={w.isPublic ? Colors.warning : Colors.text}>
499
+ {id.replace("workers/", "")}
500
+ </text>
501
+ <text dim fg={Colors.muted}>
502
+ {w.category}
503
+ </text>
504
+ {w.smartPlacement && (
505
+ <text fg={Colors.success} dim>
506
+ ⚡ Smart Placement
507
+ </text>
508
+ )}
509
+ </box>
510
+ );
511
+ })}
512
+ </box>
513
+
514
+ {/* Infrastructure Grid */}
515
+ <text bold fg={Colors.info} marginBottom={1}>
516
+ INFRASTRUCTURE
517
+ </text>
518
+ <box flexDirection="row" flexWrap="wrap">
519
+ {(activeCommunity
520
+ ? infra.filter(([id]) =>
521
+ communities
522
+ .find((c) => c.id === activeCommunity)
523
+ ?.nodeIds.includes(`infra:${id}`)
524
+ )
525
+ : infra
526
+ ).map(([id, i]) => {
527
+ const isFocused =
528
+ selectableItems[focusedIndex]?.id === id &&
529
+ selectableItems[focusedIndex]?.type === "infra";
530
+ return (
531
+ <box
532
+ key={id}
533
+ width="30%"
534
+ marginRight={1}
535
+ marginBottom={1}
536
+ padding={1}
537
+ borderStyle="rounded"
538
+ borderColor={
539
+ selectedNode === id
540
+ ? Colors.accent
541
+ : isFocused
542
+ ? Colors.foreground
543
+ : Colors.border
544
+ }
545
+ flexDirection="column"
546
+ >
547
+ <text bold fg={Colors.info}>
548
+ {i.label}
549
+ </text>
550
+ <text dim fg={Colors.muted}>
551
+ {i.category}
552
+ </text>
553
+ <text dim fg={Colors.muted}>
554
+ {i.bindingName}
555
+ </text>
556
+ </box>
557
+ );
558
+ })}
559
+ </box>
560
+ </box>
561
+
562
+ {/* Right Column: Details & Flows */}
563
+ <box flexDirection="column" width="40%" paddingLeft={2}>
564
+ <text bold fg={Colors.accent} marginBottom={1}>
565
+ {selectedNode ? "NODE DETAILS" : "SYSTEM FLOWS"}
566
+ </text>
567
+ {rightColumnContent}
568
+ </box>
569
+ </box>
570
+ </box>
571
+ );
572
+ }