@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,616 @@
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
+ /**
8
+ * Logs Viewer — Split-layout view with filter controls on left (20 cols) and
9
+ * scrolling color-coded log stream on right. Filters combine with AND logic.
10
+ * Keyboard: Space toggles pause, / focuses the search field.
11
+ *
12
+ * Uses service store logs (ring buffer) and workers list for filter checkboxes.
13
+ * Color-coded by level using Hoox design tokens. Wrapped in ErrorBoundary.
14
+ */
15
+ import { useState, useMemo, useCallback, useEffect, useRef } from "react";
16
+ import { useKeyboard } from "@opentui/react";
17
+ import {
18
+ Colors,
19
+ LogLevelColor,
20
+ useServiceStore,
21
+ useUIStore,
22
+ } from "@hoox-sh/hoox-shared";
23
+ import type { LogEntry, LogLevel, Alert } from "@hoox-sh/hoox-shared";
24
+ import { ErrorBoundary } from "../shared/error-boundary";
25
+ import { cliBridge } from "../../services/cli-bridge";
26
+ import { ViewHeader } from "../shared/view-header";
27
+ import { Panel } from "../shared/panel";
28
+
29
+ // ─── Color Tokens ────────────────────────────────────────────────────────────
30
+
31
+ const LEVEL_LABEL: Record<LogLevel, string> = {
32
+ error: "ERR",
33
+ warn: "WRN",
34
+ info: "INF",
35
+ debug: "DBG",
36
+ };
37
+
38
+ const ALL_LEVELS: LogLevel[] = ["error", "warn", "info", "debug"];
39
+
40
+ /** Max rows rendered in the stream (store ring is 1000). */
41
+ export const MAX_VISIBLE_LOGS = 200;
42
+
43
+ /** Search debounce so each keystroke doesn't refilter the whole buffer. */
44
+ const SEARCH_DEBOUNCE_MS = 150;
45
+
46
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
47
+
48
+ function formatTimestamp(ms: number): string {
49
+ const d = new Date(ms);
50
+ const h = d.getHours().toString().padStart(2, "0");
51
+ const m = d.getMinutes().toString().padStart(2, "0");
52
+ const s = d.getSeconds().toString().padStart(2, "0");
53
+ return `${h}:${m}:${s}`;
54
+ }
55
+
56
+ function truncateRight(str: string, max: number): string {
57
+ if (str.length <= max) return str;
58
+ return str.slice(0, max - 1) + "\u2026";
59
+ }
60
+
61
+ /**
62
+ * Strip C0 control chars (except tab) so log payloads cannot inject
63
+ * terminal sequences into the TUI stream.
64
+ */
65
+ export function sanitizeLogText(value: string, maxLen = 400): string {
66
+ const cleaned = value.replace(
67
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
68
+ ""
69
+ );
70
+ // Collapse newlines/tabs to spaces so each log stays one row
71
+ const singleLine = cleaned.replace(/[\t\n\r]+/g, " ");
72
+ if (singleLine.length <= maxLen) return singleLine;
73
+ return singleLine.slice(0, maxLen - 1) + "\u2026";
74
+ }
75
+
76
+ /**
77
+ * Filter logs with AND logic: level ∩ worker ∩ text search.
78
+ * Exported for unit tests so the view and suite stay in lockstep.
79
+ */
80
+ export function applyLogFilters(
81
+ entries: readonly LogEntry[],
82
+ options: {
83
+ allLevels: boolean;
84
+ levels: Set<LogLevel>;
85
+ selectedWorkers: Set<string>;
86
+ searchText: string;
87
+ workers: ReadonlyArray<{ id: string; name: string }>;
88
+ }
89
+ ): LogEntry[] {
90
+ const { allLevels, levels, selectedWorkers, searchText, workers } = options;
91
+ const activeLevels = allLevels ? new Set(ALL_LEVELS) : levels;
92
+ const query = searchText.toLowerCase().trim();
93
+
94
+ if (activeLevels.size === 0 && !allLevels) return [];
95
+
96
+ // Pre-index workers once for O(1) lookup
97
+ const workerById =
98
+ selectedWorkers.size > 0
99
+ ? new Map(workers.map((w) => [w.id, w.name] as const))
100
+ : null;
101
+
102
+ return entries.filter((entry) => {
103
+ if (!activeLevels.has(entry.level)) return false;
104
+
105
+ if (workerById) {
106
+ const name = entry.workerId ? workerById.get(entry.workerId) : undefined;
107
+ if (!name || !selectedWorkers.has(name)) return false;
108
+ }
109
+
110
+ if (query) {
111
+ const hay = `${entry.message} ${entry.source ?? ""}`.toLowerCase();
112
+ if (!hay.includes(query)) return false;
113
+ }
114
+
115
+ return true;
116
+ });
117
+ }
118
+
119
+ // ─── Filter Panel ────────────────────────────────────────────────────────────
120
+
121
+ interface FilterPanelProps {
122
+ allLevels: boolean;
123
+ onToggleAll: () => void;
124
+ levels: Set<LogLevel>;
125
+ onToggleLevel: (l: LogLevel) => void;
126
+ workerNames: string[];
127
+ selectedWorkers: Set<string>;
128
+ onToggleWorker: (name: string) => void;
129
+ searchText: string;
130
+ onSearchChange: (v: string) => void;
131
+ }
132
+
133
+ function FilterPanel({
134
+ allLevels,
135
+ onToggleAll,
136
+ levels,
137
+ onToggleLevel,
138
+ workerNames,
139
+ selectedWorkers,
140
+ onToggleWorker,
141
+ searchText,
142
+ onSearchChange,
143
+ }: FilterPanelProps) {
144
+ return (
145
+ <Panel title="FILTERS" width={20} elevated={false}>
146
+ {/* ── Level Section ───────────────────────────────────────────────── */}
147
+ <text bold fg={Colors.muted}>
148
+ LEVEL
149
+ </text>
150
+ <box flexDirection="row" gap={1}>
151
+ <text
152
+ fg={allLevels ? Colors.accent : Colors.muted}
153
+ onMouseUp={onToggleAll}
154
+ >
155
+ {allLevels ? "[x]" : "[ ]"} ALL
156
+ </text>
157
+ </box>
158
+ {ALL_LEVELS.map((lvl) => (
159
+ <box flexDirection="row" gap={1} key={lvl}>
160
+ <text
161
+ fg={levels.has(lvl) ? Colors.accent : Colors.muted}
162
+ onMouseUp={() => onToggleLevel(lvl)}
163
+ >
164
+ {levels.has(lvl) ? "[x]" : "[ ]"}
165
+ </text>
166
+ <text fg={LogLevelColor[lvl]} dim={lvl === "debug"}>
167
+ {LEVEL_LABEL[lvl]}
168
+ </text>
169
+ </box>
170
+ ))}
171
+
172
+ {/* ── Worker Section ──────────────────────────────────────────────── */}
173
+ <box height={1} />
174
+ <text bold fg={Colors.muted}>
175
+ WORKER
176
+ </text>
177
+ {workerNames.length === 0 ? (
178
+ <text dim fg={Colors.dim}>
179
+ (no workers)
180
+ </text>
181
+ ) : (
182
+ <scrollbox height={6} border={false}>
183
+ {workerNames.map((name) => (
184
+ <box flexDirection="row" gap={1} key={name}>
185
+ <text
186
+ fg={selectedWorkers.has(name) ? Colors.accent : Colors.muted}
187
+ onMouseUp={() => onToggleWorker(name)}
188
+ >
189
+ {selectedWorkers.has(name) ? "[x]" : "[ ]"}
190
+ </text>
191
+ <text fg={Colors.foreground}>{truncateRight(name, 14)}</text>
192
+ </box>
193
+ ))}
194
+ </scrollbox>
195
+ )}
196
+
197
+ {/* ── Search Section ──────────────────────────────────────────────── */}
198
+ <box height={1} />
199
+ <text bold fg={Colors.muted}>
200
+ SEARCH /
201
+ </text>
202
+ <input
203
+ id="log-search"
204
+ placeholder="Filter text..."
205
+ width={18}
206
+ textColor={Colors.foreground}
207
+ cursorColor={Colors.accent}
208
+ onInput={(v: string) => onSearchChange(v)}
209
+ value={searchText}
210
+ />
211
+ </Panel>
212
+ );
213
+ }
214
+
215
+ // ─── Log Stream ──────────────────────────────────────────────────────────────
216
+
217
+ interface LogStreamProps {
218
+ entries: LogEntry[];
219
+ paused: boolean;
220
+ }
221
+
222
+ function LogStream({ entries, paused }: LogStreamProps) {
223
+ if (entries.length === 0) {
224
+ return (
225
+ <Panel title="LOG STREAM" flexGrow={1} elevated={false}>
226
+ <box
227
+ flexGrow={1}
228
+ justifyContent="center"
229
+ alignItems="center"
230
+ flexDirection="column"
231
+ >
232
+ <text dim fg={Colors.muted}>
233
+ {paused
234
+ ? "Paused — press Space to resume"
235
+ : "No matching log entries"}
236
+ </text>
237
+ </box>
238
+ </Panel>
239
+ );
240
+ }
241
+
242
+ return (
243
+ <scrollbox flexGrow={1} border={false}>
244
+ {entries.map((entry) => {
245
+ const fg = LogLevelColor[entry.level] ?? Colors.foreground;
246
+ const dim = entry.level === "debug";
247
+ const label = LEVEL_LABEL[entry.level] ?? String(entry.level);
248
+ const time = formatTimestamp(entry.timestamp);
249
+ const src = entry.source
250
+ ? ` [${truncateRight(sanitizeLogText(entry.source, 24), 12)}]`
251
+ : "";
252
+ const workerTag = entry.workerId
253
+ ? ` (${truncateRight(sanitizeLogText(entry.workerId, 20), 10)})`
254
+ : "";
255
+ const message = sanitizeLogText(entry.message ?? "");
256
+
257
+ return (
258
+ <box flexDirection="row" gap={1} key={entry.id}>
259
+ <text dim fg={Colors.muted} selectable>
260
+ {time}
261
+ </text>
262
+ <text fg={fg} bold={entry.level === "error"} dim={dim} selectable>
263
+ {label}
264
+ </text>
265
+ <text dim fg={Colors.muted} selectable>
266
+ {src}
267
+ {workerTag}
268
+ </text>
269
+ <text fg={dim ? Colors.dim : fg} selectable>
270
+ {message}
271
+ </text>
272
+ </box>
273
+ );
274
+ })}
275
+ </scrollbox>
276
+ );
277
+ }
278
+
279
+ // ─── Action Bar ──────────────────────────────────────────────────────────────
280
+
281
+ interface ActionBarProps {
282
+ paused: boolean;
283
+ onTogglePause: () => void;
284
+ onExport: () => void;
285
+ onClear: () => void;
286
+ sseConnected: boolean;
287
+ onFetch?: () => void;
288
+ }
289
+
290
+ function ActionBar({
291
+ paused,
292
+ onTogglePause,
293
+ onExport,
294
+ onClear,
295
+ sseConnected,
296
+ onFetch,
297
+ }: ActionBarProps) {
298
+ return (
299
+ <box
300
+ flexDirection="row"
301
+ height={1}
302
+ justifyContent="space-between"
303
+ paddingLeft={1}
304
+ paddingRight={1}
305
+ border={true}
306
+ borderStyle="single"
307
+ >
308
+ <box flexDirection="row" gap={2}>
309
+ <text
310
+ fg={paused ? Colors.accent : Colors.foreground}
311
+ onMouseUp={onTogglePause}
312
+ >
313
+ [{paused ? "\u25B6 Resume" : "\u275A\u275A Pause"}]
314
+ </text>
315
+ <text fg={Colors.foreground} onMouseUp={onExport}>
316
+ [Export]
317
+ </text>
318
+ <text fg={Colors.foreground} onMouseUp={onClear}>
319
+ [Clear]
320
+ </text>
321
+ {!sseConnected && onFetch ? (
322
+ <text fg={Colors.accent} onMouseUp={onFetch}>
323
+ [FETCH]
324
+ </text>
325
+ ) : null}
326
+ </box>
327
+ <text dim fg={Colors.muted}>
328
+ Space:pause /:search Tab:switch
329
+ </text>
330
+ </box>
331
+ );
332
+ }
333
+
334
+ // ─── Main View ───────────────────────────────────────────────────────────────
335
+
336
+ export function LogsViewer() {
337
+ // ── Local state ─────────────────────────────────────────────────────────
338
+ const [paused, setPaused] = useState(false);
339
+ const [allLevels, setAllLevels] = useState(true);
340
+ const [levels, setLevels] = useState<Set<LogLevel>>(new Set());
341
+ const [selectedWorkers, setSelectedWorkers] = useState<Set<string>>(
342
+ new Set()
343
+ );
344
+ const [searchText, setSearchText] = useState("");
345
+ /** Debounced copy used for filtering (avoids O(n) refilter per keystroke). */
346
+ const [debouncedSearch, setDebouncedSearch] = useState("");
347
+ /** When true, printable keys append to searchText instead of being ignored. */
348
+ const [searchActive, setSearchActive] = useState(false);
349
+ const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
350
+
351
+ // ── Store subscriptions (selectors only) ────────────────────────────────
352
+ const allLogs = useServiceStore((s) => s.logs);
353
+ const workers = useServiceStore((s) => s.workers);
354
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
355
+ const activeView = useUIStore((s) => s.activeView);
356
+ const isActive = activeView === "logs-viewer";
357
+ const sseConnected = connectionStatus === "connected";
358
+
359
+ // Debounce search input; clear timer on unmount
360
+ useEffect(() => {
361
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
362
+ searchTimerRef.current = setTimeout(() => {
363
+ setDebouncedSearch(searchText);
364
+ searchTimerRef.current = null;
365
+ }, SEARCH_DEBOUNCE_MS);
366
+ return () => {
367
+ if (searchTimerRef.current) {
368
+ clearTimeout(searchTimerRef.current);
369
+ searchTimerRef.current = null;
370
+ }
371
+ };
372
+ }, [searchText]);
373
+
374
+ // Derive unique worker names for checkboxes
375
+ const workerNames = useMemo(() => workers.map((w) => w.name), [workers]);
376
+
377
+ // ── Toggle helpers ──────────────────────────────────────────────────────
378
+ const toggleAll = useCallback(() => {
379
+ setAllLevels((prev) => !prev);
380
+ }, []);
381
+
382
+ const toggleLevel = useCallback((lvl: LogLevel) => {
383
+ setLevels((prev) => {
384
+ const next = new Set(prev);
385
+ if (next.has(lvl)) next.delete(lvl);
386
+ else next.add(lvl);
387
+ return next;
388
+ });
389
+ }, []);
390
+
391
+ const toggleWorker = useCallback((name: string) => {
392
+ setSelectedWorkers((prev) => {
393
+ const next = new Set(prev);
394
+ if (next.has(name)) next.delete(name);
395
+ else next.add(name);
396
+ return next;
397
+ });
398
+ }, []);
399
+
400
+ const handleExport = useCallback(async () => {
401
+ const ts = Date.now();
402
+ try {
403
+ const result = await cliBridge.exec<LogEntry[]>(["logs", "all"], {
404
+ json: true,
405
+ timeout: 20_000,
406
+ });
407
+
408
+ let data: LogEntry[];
409
+ if (result.success && result.data) {
410
+ data = result.data;
411
+ } else {
412
+ data = useServiceStore.getState().logs;
413
+ }
414
+
415
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
416
+ const dirPath = `${homeDir}/.hoox`;
417
+ const filePath = `${dirPath}/logs-export-${ts}.json`;
418
+ await Bun.write(filePath, JSON.stringify(data, null, 2));
419
+
420
+ const alert: Alert = {
421
+ id: `export-${ts}`,
422
+ type: "info",
423
+ severity: "info",
424
+ message: `Exported ${data.length} logs to ${filePath}`,
425
+ timestamp: ts,
426
+ acknowledged: false,
427
+ };
428
+ useServiceStore.getState().addAlert(alert);
429
+ } catch (err) {
430
+ const alert: Alert = {
431
+ id: `export-err-${Date.now()}`,
432
+ type: "error",
433
+ severity: "error",
434
+ message: `Export failed: ${(err as Error).message}`,
435
+ timestamp: Date.now(),
436
+ acknowledged: false,
437
+ };
438
+ useServiceStore.getState().addAlert(alert);
439
+ }
440
+ }, []);
441
+
442
+ const handleClear = useCallback(() => {
443
+ // Soft confirm via status alert path — clearing is local buffer only.
444
+ // (No dialog prop on this view; keep non-destructive.)
445
+ const ts = Date.now();
446
+ useServiceStore.setState((state) => {
447
+ state.logs = [];
448
+ });
449
+ const alert: Alert = {
450
+ id: `clear-${ts}`,
451
+ type: "info",
452
+ severity: "info",
453
+ message: "Log buffer cleared",
454
+ timestamp: ts,
455
+ acknowledged: false,
456
+ };
457
+ useServiceStore.getState().addAlert(alert);
458
+ }, []);
459
+
460
+ const handleFetch = useCallback(async () => {
461
+ const ts = Date.now();
462
+ let fetchedCount = 0;
463
+ const names = useServiceStore.getState().workers.map((w) => w.name);
464
+ if (names.length === 0) {
465
+ names.push("all");
466
+ }
467
+ for (const name of names) {
468
+ try {
469
+ const result = await cliBridge.workerLogs(name);
470
+ if (result.success && Array.isArray(result.data)) {
471
+ const entries = result.data as LogEntry[];
472
+ for (const entry of entries) {
473
+ useServiceStore.getState().pushLog(entry);
474
+ }
475
+ fetchedCount += entries.length;
476
+ }
477
+ } catch {
478
+ // skip failed worker
479
+ }
480
+ }
481
+ const alert: Alert = {
482
+ id: `fetch-${ts}`,
483
+ type: "info",
484
+ severity: "info",
485
+ message:
486
+ fetchedCount > 0
487
+ ? `Fetched ${fetchedCount} logs from CLI`
488
+ : "No logs fetched from CLI",
489
+ timestamp: ts,
490
+ acknowledged: false,
491
+ };
492
+ useServiceStore.getState().addAlert(alert);
493
+ }, []);
494
+
495
+ // ── Filter logic (AND combination) ──────────────────────────────────────
496
+ const filteredLogs = useMemo(
497
+ () =>
498
+ applyLogFilters(allLogs, {
499
+ allLevels,
500
+ levels,
501
+ selectedWorkers,
502
+ searchText: debouncedSearch,
503
+ workers,
504
+ }),
505
+ [allLogs, allLevels, levels, selectedWorkers, debouncedSearch, workers]
506
+ );
507
+
508
+ // Display newest-first, capped for render performance
509
+ const displayed = useMemo(() => {
510
+ const newestFirst = filteredLogs.slice().reverse();
511
+ return newestFirst.slice(0, MAX_VISIBLE_LOGS);
512
+ }, [filteredLogs]);
513
+
514
+ // ── Paused buffer ───────────────────────────────────────────────────────
515
+ const [frozen, setFrozen] = useState<LogEntry[] | null>(null);
516
+ const visible = paused && frozen !== null ? frozen : displayed;
517
+
518
+ // Capture snapshot when pausing, clear when resuming
519
+ useEffect(() => {
520
+ if (paused && frozen === null) {
521
+ setFrozen(displayed);
522
+ }
523
+ if (!paused && frozen !== null) {
524
+ setFrozen(null);
525
+ }
526
+ }, [paused, frozen, displayed]);
527
+
528
+ // ── Keyboard (active view only) ─────────────────────────────────────────
529
+ useKeyboard((key: { name: string; sequence?: string; ctrl?: boolean }) => {
530
+ if (!isActive) return;
531
+
532
+ // Search mode: type to filter, Esc / Enter to leave
533
+ if (searchActive) {
534
+ if (key.name === "escape" || key.name === "return") {
535
+ setSearchActive(false);
536
+ return;
537
+ }
538
+ if (key.name === "backspace" || key.name === "delete") {
539
+ setSearchText((t) => t.slice(0, -1));
540
+ return;
541
+ }
542
+ if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
543
+ setSearchText((t) => t + key.sequence);
544
+ }
545
+ return;
546
+ }
547
+
548
+ if (key.name === "space") {
549
+ setPaused((p) => !p);
550
+ return;
551
+ }
552
+ if (key.name === "slash" || key.name === "/") {
553
+ setSearchActive(true);
554
+ return;
555
+ }
556
+ });
557
+
558
+ return (
559
+ <ErrorBoundary viewName="Logs Viewer">
560
+ <box flexDirection="column" flexGrow={1} padding={0} gap={1}>
561
+ {/* ── Title bar ────────────────────────────────────────────────────── */}
562
+ <ViewHeader
563
+ title="LOGS VIEWER"
564
+ showDivider={false}
565
+ meta={
566
+ <box flexDirection="row" gap={2} alignItems="center">
567
+ <text dim fg={Colors.muted}>
568
+ {filteredLogs.length}/{allLogs.length} entries
569
+ {filteredLogs.length > MAX_VISIBLE_LOGS
570
+ ? ` · showing ${MAX_VISIBLE_LOGS}`
571
+ : ""}
572
+ {paused ? " [PAUSED]" : ""}
573
+ {searchActive ? " [SEARCH]" : ""}
574
+ </text>
575
+ <text
576
+ fg={sseConnected ? Colors.success : Colors.warning}
577
+ dim={!sseConnected}
578
+ >
579
+ {sseConnected ? "SSE" : "CLI"}
580
+ </text>
581
+ <text dim fg={Colors.dim}>
582
+ Space pause · / search
583
+ </text>
584
+ </box>
585
+ }
586
+ />
587
+
588
+ {/* ── Split: Filters | Stream ──────────────────────────────────────── */}
589
+ <box flexDirection="row" flexGrow={1} gap={1}>
590
+ <FilterPanel
591
+ allLevels={allLevels}
592
+ onToggleAll={toggleAll}
593
+ levels={levels}
594
+ onToggleLevel={toggleLevel}
595
+ workerNames={workerNames}
596
+ selectedWorkers={selectedWorkers}
597
+ onToggleWorker={toggleWorker}
598
+ searchText={searchText}
599
+ onSearchChange={setSearchText}
600
+ />
601
+ <LogStream entries={visible} paused={paused} />
602
+ </box>
603
+
604
+ {/* ── Action Bar footer ────────────────────────────────────────────── */}
605
+ <ActionBar
606
+ paused={paused}
607
+ onTogglePause={() => setPaused((p) => !p)}
608
+ onExport={handleExport}
609
+ onClear={handleClear}
610
+ sseConnected={sseConnected}
611
+ onFetch={handleFetch}
612
+ />
613
+ </box>
614
+ </ErrorBoundary>
615
+ );
616
+ }