salvetron 0.1.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 (60) hide show
  1. package/README.md +333 -0
  2. package/bin/salvetron.tsx +9 -0
  3. package/package.json +43 -0
  4. package/scripts/salvetron-sim.mjs +75 -0
  5. package/src/app.tsx +62 -0
  6. package/src/modules/dashboard/store/dashboard.store.ts +19 -0
  7. package/src/modules/dashboard/ui/components/metric-row/index.ts +1 -0
  8. package/src/modules/dashboard/ui/components/metric-row/metric-row.tsx +25 -0
  9. package/src/modules/dashboard/ui/components/performance-panel/index.ts +1 -0
  10. package/src/modules/dashboard/ui/components/performance-panel/performance-panel.tsx +67 -0
  11. package/src/modules/dashboard/ui/containers/dashboard-container/dashboard-container.tsx +290 -0
  12. package/src/modules/dashboard/ui/containers/dashboard-container/index.ts +1 -0
  13. package/src/modules/js-logs/store/js-logs.store.ts +21 -0
  14. package/src/modules/js-logs/ui/components/log-detail/index.ts +1 -0
  15. package/src/modules/js-logs/ui/components/log-detail/log-detail.tsx +62 -0
  16. package/src/modules/js-logs/ui/components/log-list/index.ts +1 -0
  17. package/src/modules/js-logs/ui/components/log-list/log-list.tsx +32 -0
  18. package/src/modules/js-logs/ui/containers/js-logs-container/index.ts +1 -0
  19. package/src/modules/js-logs/ui/containers/js-logs-container/js-logs-container.tsx +80 -0
  20. package/src/modules/native-logs/store/native-logs.store.ts +21 -0
  21. package/src/modules/native-logs/ui/components/native-log-detail/index.ts +1 -0
  22. package/src/modules/native-logs/ui/components/native-log-detail/native-log-detail.tsx +63 -0
  23. package/src/modules/native-logs/ui/components/native-log-list/index.ts +1 -0
  24. package/src/modules/native-logs/ui/components/native-log-list/native-log-list.tsx +33 -0
  25. package/src/modules/native-logs/ui/containers/native-logs-container/index.ts +1 -0
  26. package/src/modules/native-logs/ui/containers/native-logs-container/native-logs-container.tsx +80 -0
  27. package/src/modules/network/library/constants.ts +15 -0
  28. package/src/modules/network/store/network.store.ts +58 -0
  29. package/src/modules/network/ui/components/network-detail/index.ts +1 -0
  30. package/src/modules/network/ui/components/network-detail/network-detail.tsx +82 -0
  31. package/src/modules/network/ui/components/network-row/index.ts +1 -0
  32. package/src/modules/network/ui/components/network-row/network-row.tsx +23 -0
  33. package/src/modules/network/ui/components/network-table-header/index.ts +1 -0
  34. package/src/modules/network/ui/components/network-table-header/network-table-header.tsx +12 -0
  35. package/src/modules/network/ui/containers/network-container/index.ts +1 -0
  36. package/src/modules/network/ui/containers/network-container/network-container.tsx +93 -0
  37. package/src/server/ws-server.ts +52 -0
  38. package/src/shared/components/ascii-logo/ascii-logo.tsx +169 -0
  39. package/src/shared/components/ascii-logo/ascii.txt +0 -0
  40. package/src/shared/components/ascii-logo/index.ts +1 -0
  41. package/src/shared/components/gauge/gauge.tsx +18 -0
  42. package/src/shared/components/gauge/index.ts +1 -0
  43. package/src/shared/components/log-row/index.ts +1 -0
  44. package/src/shared/components/log-row/log-row.tsx +28 -0
  45. package/src/shared/components/panel/index.ts +1 -0
  46. package/src/shared/components/panel/panel.tsx +28 -0
  47. package/src/shared/components/sparkline/index.ts +1 -0
  48. package/src/shared/components/sparkline/sparkline.tsx +26 -0
  49. package/src/shared/components/status-bar/index.ts +1 -0
  50. package/src/shared/components/status-bar/status-bar.tsx +32 -0
  51. package/src/shared/components/tab-bar/index.ts +1 -0
  52. package/src/shared/components/tab-bar/tab-bar.tsx +36 -0
  53. package/src/shared/hooks/use-detail-panel.ts +78 -0
  54. package/src/shared/hooks/use-list-navigation.ts +44 -0
  55. package/src/shared/hooks/use-terminal-size.ts +42 -0
  56. package/src/shared/store/device.store.ts +25 -0
  57. package/src/shared/types.ts +3 -0
  58. package/src/shared/utils/build-curl-command.ts +19 -0
  59. package/src/shared/utils/clipboard.ts +27 -0
  60. package/src/shared/utils/format-body.ts +21 -0
@@ -0,0 +1,290 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useTerminalSize } from "../../../../../shared/hooks/use-terminal-size.js";
4
+ import { useListNavigation } from "../../../../../shared/hooks/use-list-navigation.js";
5
+ import { useDetailPanel } from "../../../../../shared/hooks/use-detail-panel.js";
6
+ import { Panel } from "../../../../../shared/components/panel/index.js";
7
+ import { formatBody, formatPlainBody } from "../../../../../shared/utils/format-body.js";
8
+ import { buildCurlCommand } from "../../../../../shared/utils/build-curl-command.js";
9
+ import {
10
+ useDashboardSnapshots,
11
+ useLatestSnapshot,
12
+ } from "../../../store/dashboard.store.js";
13
+ import { PerformancePanel } from "../../components/performance-panel/index.js";
14
+ import { useJsLogs } from "../../../../js-logs/store/js-logs.store.js";
15
+ import { LogList } from "../../../../js-logs/ui/components/log-list/index.js";
16
+ import { LogDetail } from "../../../../js-logs/ui/components/log-detail/index.js";
17
+ import { useNetworkLogs } from "../../../../network/store/network.store.js";
18
+ import { NetworkTableHeader } from "../../../../network/ui/components/network-table-header/index.js";
19
+ import { NetworkRow } from "../../../../network/ui/components/network-row/index.js";
20
+ import { NetworkDetail } from "../../../../network/ui/components/network-detail/index.js";
21
+ import { useNativeLogs } from "../../../../native-logs/store/native-logs.store.js";
22
+ import { NativeLogList } from "../../../../native-logs/ui/components/native-log-list/index.js";
23
+ import { NativeLogDetail } from "../../../../native-logs/ui/components/native-log-detail/index.js";
24
+ import type { LogEvent, NativeLogEvent, NetworkLog } from "@salve-software/salvetron-types";
25
+
26
+ type FocusPanel = "logs" | "network" | "native";
27
+ const PANELS: FocusPanel[] = ["logs", "network", "native"];
28
+
29
+ // Chrome rendered by App around DashboardContainer's content area:
30
+ // AsciiLogo (6) + TabBar (marginTop 2 + content 1 + borderBottom 1 = 4) +
31
+ // content wrapper paddingTop (1) = 11 above; StatusBar (borderTop 1 +
32
+ // content 1 = 2) + content wrapper paddingBottom (1) = 3 below.
33
+ const APP_OVERHEAD_ROWS = 14;
34
+ const FOOTER_ROWS = 1;
35
+ const PERF_PANEL_ROWS = 7;
36
+ const PANEL_CHROME_ROWS = 3; // title (1) + border (2) per list Panel
37
+ const NETWORK_HEADER_ROWS = 1;
38
+ // NetworkDetail's leading content (border + header + url + req headers + res
39
+ // headers + body header) can take up to 6 rows, plus the wrapper Box's own
40
+ // top/bottom border (2 rows) — use that as the shared budget for all 3 detail
41
+ // types so content never exceeds maxHeight (overflow="hidden" + an
42
+ // over-budget frame corrupts Ink's redraw).
43
+ const DETAIL_FIXED_ROWS = 8;
44
+
45
+ export function DashboardContainer() {
46
+ const [cols, rows] = useTerminalSize();
47
+ const [focused, setFocused] = useState<FocusPanel>("logs");
48
+
49
+ const snapshots = useDashboardSnapshots();
50
+ const latest = useLatestSnapshot();
51
+ const jsLogs = useJsLogs();
52
+ const networkLogs = useNetworkLogs();
53
+ const nativeLogs = useNativeLogs();
54
+
55
+ const panelInner = Math.max(10, Math.floor(cols / 3) - 4);
56
+ const sparkWidth = Math.max(4, panelInner - 27);
57
+ const urlMaxWidth = Math.max(6, panelInner - 28);
58
+ const logsMsgWidth = Math.max(8, panelInner - 14);
59
+ const nativeMsgWidth = Math.max(8, panelInner - 26);
60
+
61
+ // Row budget derived only from the terminal's real row count — never from
62
+ // measured/rendered content, which would feed back into itself and loop.
63
+ const availableRows = rows - APP_OVERHEAD_ROWS - FOOTER_ROWS;
64
+ const logsPanelRows = Math.max(
65
+ 1,
66
+ Math.floor((availableRows - PERF_PANEL_ROWS) / 3) - PANEL_CHROME_ROWS,
67
+ );
68
+ const nativePanelRows = logsPanelRows;
69
+ const networkPanelRows = Math.max(1, logsPanelRows - NETWORK_HEADER_ROWS);
70
+
71
+ const detailRows = availableRows;
72
+ const detailBodyVisibleRows = Math.max(1, detailRows - DETAIL_FIXED_ROWS);
73
+
74
+ const logsLinesRef = useRef<string[]>([]);
75
+ const netLinesRef = useRef<string[]>([]);
76
+ const nativeLinesRef = useRef<string[]>([]);
77
+
78
+ const selLogRef = useRef<LogEvent | null>(null);
79
+ const selNetRef = useRef<NetworkLog | null>(null);
80
+ const selNativeRef = useRef<NativeLogEvent | null>(null);
81
+
82
+ const onCopyLogBody = useCallback(() => {
83
+ const log = selLogRef.current;
84
+ if (!log) return "";
85
+ const meta = log.metadata ? formatPlainBody(JSON.stringify(log.metadata)) : "";
86
+ return meta ? `${log.message}\n\n${meta}` : log.message;
87
+ }, []);
88
+ const onCopyNativeBody = useCallback(() => {
89
+ const log = selNativeRef.current;
90
+ if (!log) return "";
91
+ const meta = log.metadata ? formatPlainBody(JSON.stringify(log.metadata)) : "";
92
+ return meta ? `${log.message}\n\n${meta}` : log.message;
93
+ }, []);
94
+ const onCopyNetBody = useCallback(() => {
95
+ const log = selNetRef.current;
96
+ return log ? formatPlainBody(log.responseBody) : "";
97
+ }, []);
98
+ const onCopyNetExtra = useCallback(() => {
99
+ const log = selNetRef.current;
100
+ return log ? buildCurlCommand(log) : "";
101
+ }, []);
102
+
103
+ const logsDetail = useDetailPanel({
104
+ linesRef: logsLinesRef,
105
+ visibleRows: detailBodyVisibleRows,
106
+ scrollStep: 5,
107
+ isActive: focused === "logs",
108
+ onCopyBody: onCopyLogBody,
109
+ });
110
+ const netDetail = useDetailPanel({
111
+ linesRef: netLinesRef,
112
+ visibleRows: detailBodyVisibleRows,
113
+ scrollStep: 5,
114
+ isActive: focused === "network",
115
+ onCopyBody: onCopyNetBody,
116
+ onCopyExtra: onCopyNetExtra,
117
+ });
118
+ const nativeDetail = useDetailPanel({
119
+ linesRef: nativeLinesRef,
120
+ visibleRows: detailBodyVisibleRows,
121
+ scrollStep: 5,
122
+ isActive: focused === "native",
123
+ onCopyBody: onCopyNativeBody,
124
+ });
125
+
126
+ const focusedDetailOpen =
127
+ (focused === "logs" && logsDetail.detailOpen) ||
128
+ (focused === "network" && netDetail.detailOpen) ||
129
+ (focused === "native" && nativeDetail.detailOpen);
130
+
131
+ const logsNav = useListNavigation({
132
+ count: jsLogs.length,
133
+ visibleRows: logsPanelRows,
134
+ isActive: focused === "logs",
135
+ });
136
+ const netNav = useListNavigation({
137
+ count: networkLogs.length,
138
+ visibleRows: networkPanelRows,
139
+ isActive: focused === "network",
140
+ });
141
+ const nativeNav = useListNavigation({
142
+ count: nativeLogs.length,
143
+ visibleRows: nativePanelRows,
144
+ isActive: focused === "native",
145
+ });
146
+
147
+ const selLog = jsLogs[logsNav.selectedIndex] ?? null;
148
+ const selNet = networkLogs[netNav.selectedIndex] ?? null;
149
+ const selNative = nativeLogs[nativeNav.selectedIndex] ?? null;
150
+
151
+ const logMeta = useMemo(
152
+ () => (selLog?.metadata ? formatBody(JSON.stringify(selLog.metadata)) : []),
153
+ [selLog],
154
+ );
155
+ const netBody = useMemo(() => formatBody(selNet?.responseBody), [selNet]);
156
+ const nativeMeta = useMemo(
157
+ () => (selNative?.metadata ? formatBody(JSON.stringify(selNative.metadata)) : []),
158
+ [selNative],
159
+ );
160
+ logsLinesRef.current = logMeta;
161
+ netLinesRef.current = netBody;
162
+ nativeLinesRef.current = nativeMeta;
163
+ selLogRef.current = selLog;
164
+ selNetRef.current = selNet;
165
+ selNativeRef.current = selNative;
166
+
167
+ useEffect(() => {
168
+ logsDetail.resetDetailScroll();
169
+ }, [logsNav.selectedIndex, logsDetail.resetDetailScroll]);
170
+ useEffect(() => {
171
+ netDetail.resetDetailScroll();
172
+ }, [netNav.selectedIndex, netDetail.resetDetailScroll]);
173
+ useEffect(() => {
174
+ nativeDetail.resetDetailScroll();
175
+ }, [nativeNav.selectedIndex, nativeDetail.resetDetailScroll]);
176
+
177
+ useInput((_input, key) => {
178
+ if (key.leftArrow) {
179
+ const i = PANELS.indexOf(focused);
180
+ setFocused(PANELS[(i - 1 + PANELS.length) % PANELS.length]);
181
+ }
182
+ if (key.rightArrow) {
183
+ const i = PANELS.indexOf(focused);
184
+ setFocused(PANELS[(i + 1) % PANELS.length]);
185
+ }
186
+ });
187
+
188
+ return (
189
+ <Box flexDirection="column" flexGrow={1}>
190
+ <Box flexDirection="row" flexGrow={1}>
191
+ <Box flexDirection="column">
192
+ <PerformancePanel
193
+ latest={latest}
194
+ snapshots={snapshots}
195
+ sparkWidth={sparkWidth}
196
+ height={PERF_PANEL_ROWS}
197
+ />
198
+
199
+ <Panel title="Logs" focused={focused === "logs"} flexGrow={1}>
200
+ <LogList
201
+ logs={jsLogs}
202
+ visibleRows={logsPanelRows}
203
+ selectedIndex={logsNav.selectedIndex}
204
+ scrollOffset={logsNav.scrollOffset}
205
+ showHeader={false}
206
+ maxMessageWidth={logsMsgWidth}
207
+ />
208
+ </Panel>
209
+
210
+ <Panel title="Network" focused={focused === "network"} flexGrow={1}>
211
+ <NetworkTableHeader />
212
+ {networkLogs
213
+ .slice(
214
+ netNav.scrollOffset,
215
+ netNav.scrollOffset + networkPanelRows,
216
+ )
217
+ .map((log, i) => {
218
+ const absoluteIndex = netNav.scrollOffset + i;
219
+ return (
220
+ <NetworkRow
221
+ key={log.requestId}
222
+ log={log}
223
+ urlMaxWidth={urlMaxWidth}
224
+ isSelected={absoluteIndex === netNav.selectedIndex}
225
+ />
226
+ );
227
+ })}
228
+ </Panel>
229
+
230
+ <Panel title="Native" focused={focused === "native"} flexGrow={1}>
231
+ <NativeLogList
232
+ logs={nativeLogs}
233
+ visibleRows={nativePanelRows}
234
+ selectedIndex={nativeNav.selectedIndex}
235
+ scrollOffset={nativeNav.scrollOffset}
236
+ showHeader={false}
237
+ maxMessageWidth={nativeMsgWidth}
238
+ />
239
+ </Panel>
240
+ </Box>
241
+ <Box
242
+ flexDirection="column"
243
+ height={focusedDetailOpen ? undefined : 0}
244
+ maxHeight={focusedDetailOpen ? detailRows : 0}
245
+ overflow="hidden"
246
+ flexGrow={1}
247
+ borderStyle="single"
248
+ borderColor="gray"
249
+ paddingX={2}
250
+ >
251
+ {focused === "logs" && logsDetail.detailOpen && selLog ? (
252
+ <LogDetail
253
+ log={selLog}
254
+ width={cols}
255
+ metaLines={logMeta}
256
+ metaScrollOffset={logsDetail.detailScrollOffset}
257
+ metaVisibleRows={detailBodyVisibleRows}
258
+ copyFeedback={logsDetail.copyFeedback}
259
+ />
260
+ ) : null}
261
+ {focused === "network" && netDetail.detailOpen && selNet ? (
262
+ <NetworkDetail
263
+ log={selNet}
264
+ width={cols}
265
+ bodyLines={netBody}
266
+ bodyScrollOffset={netDetail.detailScrollOffset}
267
+ bodyVisibleRows={detailBodyVisibleRows}
268
+ copyFeedback={netDetail.copyFeedback}
269
+ />
270
+ ) : null}
271
+ {focused === "native" && nativeDetail.detailOpen && selNative ? (
272
+ <NativeLogDetail
273
+ log={selNative}
274
+ width={cols}
275
+ metaLines={nativeMeta}
276
+ metaScrollOffset={nativeDetail.detailScrollOffset}
277
+ metaVisibleRows={detailBodyVisibleRows}
278
+ copyFeedback={nativeDetail.copyFeedback}
279
+ />
280
+ ) : null}
281
+ </Box>
282
+ </Box>
283
+ <Box>
284
+ <Text color="whiteBright" dimColor>
285
+ ←→ panel · ↑↓ navigate · ↵ open · esc close · [ ] scroll detail · c copy{focused === "network" ? " · u curl" : ""}
286
+ </Text>
287
+ </Box>
288
+ </Box>
289
+ );
290
+ }
@@ -0,0 +1 @@
1
+ export { DashboardContainer } from './dashboard-container'
@@ -0,0 +1,21 @@
1
+ import { create } from 'zustand'
2
+ import { useShallow } from 'zustand/react/shallow'
3
+ import type { LogEvent } from '@salve-software/salvetron-types'
4
+
5
+ interface JsLogsStore {
6
+ logs: LogEvent[]
7
+ addLog: (log: LogEvent) => void
8
+ clear: () => void
9
+ }
10
+
11
+ const MAX = 500
12
+
13
+ export const useJsLogsStore = create<JsLogsStore>((set) => ({
14
+ logs: [],
15
+ addLog: (log) => set((s) => ({ logs: [...s.logs, log].slice(-MAX) })),
16
+ clear: () => set({ logs: [] }),
17
+ }))
18
+
19
+ export const useJsLogs = () => useJsLogsStore(useShallow((s) => s.logs))
20
+ export const useRecentJsLogs = (n: number) =>
21
+ useJsLogsStore(useShallow((s) => s.logs.slice(-n)))
@@ -0,0 +1 @@
1
+ export { LogDetail } from './log-detail'
@@ -0,0 +1,62 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { LogEvent } from '@salve-software/salvetron-types'
3
+ import type { CopyFeedback } from '../../../../../shared/hooks/use-detail-panel.js'
4
+
5
+ const LEVEL_COLOR: Record<string, string> = {
6
+ error: 'red', warn: 'yellow', info: 'cyan', debug: 'gray', log: 'white',
7
+ }
8
+
9
+ interface LogDetailProps {
10
+ log: LogEvent
11
+ width: number
12
+ metaLines: string[]
13
+ metaScrollOffset: number
14
+ metaVisibleRows: number
15
+ copyFeedback?: CopyFeedback | null
16
+ }
17
+
18
+ export function LogDetail({ log, width, metaLines, metaScrollOffset, metaVisibleRows, copyFeedback }: LogDetailProps) {
19
+ const color = LEVEL_COLOR[log.level] ?? 'white'
20
+ const time = new Date(log.timestamp).toLocaleTimeString('en', { hour12: false })
21
+ const visibleLines = metaLines.slice(metaScrollOffset, metaScrollOffset + metaVisibleRows)
22
+ const canScroll = metaLines.length > metaVisibleRows
23
+
24
+ return (
25
+ <Box
26
+ flexDirection="column"
27
+ borderStyle="single"
28
+ borderColor="gray"
29
+ borderTop={true}
30
+ borderBottom={false}
31
+ borderLeft={false}
32
+ borderRight={false}
33
+ paddingX={1}
34
+ >
35
+ <Box gap={2}>
36
+ <Text color={color} bold>[{log.level.toUpperCase()}]</Text>
37
+ <Text color="gray">{time}</Text>
38
+ <Text color="gray" dimColor>src: {log.source}</Text>
39
+ {copyFeedback
40
+ ? <Text color={copyFeedback.success ? 'green' : 'red'}>{copyFeedback.success ? '✓ Copied' : '✗ Copy failed'}</Text>
41
+ : null
42
+ }
43
+ </Box>
44
+ <Text wrap="wrap">{log.message.slice(0, width * 3)}</Text>
45
+ {metaLines.length > 0
46
+ ?
47
+ <Box flexDirection="column">
48
+ <Text color="whiteBright" dimColor>
49
+ {'── metadata'}
50
+ {canScroll ? ` [ = up ] = down · line ${metaScrollOffset + 1} of ${metaLines.length}` : ''}
51
+ {' · c copy'}
52
+ {' ──'}
53
+ </Text>
54
+ {visibleLines.map((line, i) => (
55
+ <Text key={i}>{line}</Text>
56
+ ))}
57
+ </Box>
58
+ : null
59
+ }
60
+ </Box>
61
+ )
62
+ }
@@ -0,0 +1 @@
1
+ export { LogList } from './log-list'
@@ -0,0 +1,32 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { LogEvent } from '@salve-software/salvetron-types'
3
+ import { LogRow } from '../../../../../shared/components/log-row/index.js'
4
+
5
+ interface LogListProps {
6
+ logs: LogEvent[]
7
+ visibleRows: number
8
+ selectedIndex: number
9
+ scrollOffset: number
10
+ showHeader?: boolean
11
+ maxMessageWidth?: number
12
+ }
13
+
14
+ export function LogList({ logs, visibleRows, selectedIndex, scrollOffset, showHeader = true, maxMessageWidth }: LogListProps) {
15
+ const visible = logs.slice(scrollOffset, scrollOffset + visibleRows)
16
+
17
+ return (
18
+ <Box flexDirection="column">
19
+ {showHeader ? <Text color="gray" dimColor>{logs.length} logs · ↑↓ navigate</Text> : null}
20
+ {visible.map((log, i) => {
21
+ const absoluteIndex = scrollOffset + i
22
+ const isSelected = absoluteIndex === selectedIndex
23
+ return (
24
+ <Box key={i} gap={1}>
25
+ <Text color="cyan">{isSelected ? '▶' : ' '}</Text>
26
+ <LogRow log={log} maxMessageWidth={maxMessageWidth} />
27
+ </Box>
28
+ )
29
+ })}
30
+ </Box>
31
+ )
32
+ }
@@ -0,0 +1 @@
1
+ export { JsLogsContainer } from './js-logs-container'
@@ -0,0 +1,80 @@
1
+ import { Box } from 'ink'
2
+ import { useCallback, useEffect, useRef } from 'react'
3
+ import { useTerminalSize } from '../../../../../shared/hooks/use-terminal-size.js'
4
+ import { useListNavigation } from '../../../../../shared/hooks/use-list-navigation.js'
5
+ import { useDetailPanel } from '../../../../../shared/hooks/use-detail-panel.js'
6
+ import { useJsLogs } from '../../../store/js-logs.store.js'
7
+ import { LogList } from '../../components/log-list/index.js'
8
+ import { LogDetail } from '../../components/log-detail/index.js'
9
+ import { formatBody, formatPlainBody } from '../../../../../shared/utils/format-body.js'
10
+ import type { LogEvent } from '@salve-software/salvetron-types'
11
+
12
+ const OVERHEAD_ROWS = 6
13
+ const DETAIL_FIXED_ROWS = 3
14
+ const MIN_LIST_ROWS = 10
15
+
16
+ export function JsLogsContainer() {
17
+ const [cols, rows] = useTerminalSize()
18
+ const logs = useJsLogs()
19
+
20
+ const availableRows = rows - OVERHEAD_ROWS
21
+ const detailHeight = Math.max(DETAIL_FIXED_ROWS + 2, availableRows - MIN_LIST_ROWS)
22
+ const metaVisibleRows = detailHeight - DETAIL_FIXED_ROWS
23
+
24
+ const metaLinesRef = useRef<string[]>([])
25
+ const selectedLogRef = useRef<LogEvent | null>(null)
26
+
27
+ const onCopyBody = useCallback(() => {
28
+ const log = selectedLogRef.current
29
+ if (!log) return ''
30
+ const meta = log.metadata ? formatPlainBody(JSON.stringify(log.metadata)) : ''
31
+ return meta ? `${log.message}\n\n${meta}` : log.message
32
+ }, [])
33
+
34
+ const { detailOpen, detailScrollOffset, resetDetailScroll, copyFeedback } = useDetailPanel({
35
+ linesRef: metaLinesRef,
36
+ visibleRows: metaVisibleRows,
37
+ scrollStep:5,
38
+ onCopyBody,
39
+ })
40
+
41
+ const listRows = detailOpen
42
+ ? Math.max(MIN_LIST_ROWS, availableRows - detailHeight)
43
+ : availableRows
44
+
45
+ const { selectedIndex, scrollOffset } = useListNavigation({ count: logs.length, visibleRows: listRows })
46
+
47
+ const selectedLog = logs[selectedIndex] ?? null
48
+ const metaLines = selectedLog?.metadata ? formatBody(JSON.stringify(selectedLog.metadata)) : []
49
+ metaLinesRef.current = metaLines
50
+ selectedLogRef.current = selectedLog
51
+
52
+ useEffect(() => {
53
+ resetDetailScroll()
54
+ }, [selectedIndex, resetDetailScroll])
55
+
56
+ return (
57
+ <Box flexDirection="column">
58
+ <Box flexGrow={1}>
59
+ <LogList
60
+ logs={logs}
61
+ visibleRows={listRows}
62
+ selectedIndex={selectedIndex}
63
+ scrollOffset={scrollOffset}
64
+ />
65
+ </Box>
66
+ {detailOpen && selectedLog
67
+ ?
68
+ <LogDetail
69
+ log={selectedLog}
70
+ width={cols}
71
+ metaLines={metaLines}
72
+ metaScrollOffset={detailScrollOffset}
73
+ metaVisibleRows={metaVisibleRows}
74
+ copyFeedback={copyFeedback}
75
+ />
76
+ : null
77
+ }
78
+ </Box>
79
+ )
80
+ }
@@ -0,0 +1,21 @@
1
+ import { create } from 'zustand'
2
+ import { useShallow } from 'zustand/react/shallow'
3
+ import type { NativeLogEvent } from '@salve-software/salvetron-types'
4
+
5
+ interface NativeLogsStore {
6
+ logs: NativeLogEvent[]
7
+ addLog: (log: NativeLogEvent) => void
8
+ clear: () => void
9
+ }
10
+
11
+ const MAX = 500
12
+
13
+ export const useNativeLogsStore = create<NativeLogsStore>((set) => ({
14
+ logs: [],
15
+ addLog: (log) => set((s) => ({ logs: [...s.logs, log].slice(-MAX) })),
16
+ clear: () => set({ logs: [] }),
17
+ }))
18
+
19
+ export const useNativeLogs = () => useNativeLogsStore(useShallow((s) => s.logs))
20
+ export const useRecentNativeLogs = (n: number) =>
21
+ useNativeLogsStore(useShallow((s) => s.logs.slice(-n)))
@@ -0,0 +1 @@
1
+ export { NativeLogDetail } from './native-log-detail'
@@ -0,0 +1,63 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { NativeLogEvent } from '@salve-software/salvetron-types'
3
+ import type { CopyFeedback } from '../../../../../shared/hooks/use-detail-panel.js'
4
+
5
+ const LEVEL_COLOR: Record<string, string> = {
6
+ error: 'red', warn: 'yellow', info: 'cyan', debug: 'gray', log: 'white',
7
+ }
8
+
9
+ interface NativeLogDetailProps {
10
+ log: NativeLogEvent
11
+ width: number
12
+ metaLines: string[]
13
+ metaScrollOffset: number
14
+ metaVisibleRows: number
15
+ copyFeedback?: CopyFeedback | null
16
+ }
17
+
18
+ export function NativeLogDetail({ log, width, metaLines, metaScrollOffset, metaVisibleRows, copyFeedback }: NativeLogDetailProps) {
19
+ const color = LEVEL_COLOR[log.level] ?? 'white'
20
+ const time = new Date(log.timestamp).toLocaleTimeString('en', { hour12: false })
21
+ const visibleLines = metaLines.slice(metaScrollOffset, metaScrollOffset + metaVisibleRows)
22
+ const canScroll = metaLines.length > metaVisibleRows
23
+
24
+ return (
25
+ <Box
26
+ flexDirection="column"
27
+ borderStyle="single"
28
+ borderColor="gray"
29
+ borderTop={true}
30
+ borderBottom={false}
31
+ borderLeft={false}
32
+ borderRight={false}
33
+ paddingX={1}
34
+ >
35
+ <Box gap={2}>
36
+ <Text color={color} bold>[{log.level.toUpperCase()}]</Text>
37
+ <Text color="whiteBright">{time}</Text>
38
+ <Text color="whiteBright" dimColor>[{log.source}]</Text>
39
+ {log.tag ? <Text color="whiteBright" dimColor>tag: {log.tag}</Text> : null}
40
+ {copyFeedback
41
+ ? <Text color={copyFeedback.success ? 'green' : 'red'}>{copyFeedback.success ? '✓ Copied' : '✗ Copy failed'}</Text>
42
+ : null
43
+ }
44
+ </Box>
45
+ <Text wrap="wrap">{log.message.slice(0, width * 3)}</Text>
46
+ {metaLines.length > 0
47
+ ?
48
+ <Box flexDirection="column">
49
+ <Text color="whiteBright" dimColor>
50
+ {'── metadata'}
51
+ {canScroll ? ` [ = up ] = down · line ${metaScrollOffset + 1} of ${metaLines.length}` : ''}
52
+ {' · c copy'}
53
+ {' ──'}
54
+ </Text>
55
+ {visibleLines.map((line, i) => (
56
+ <Text key={i}>{line}</Text>
57
+ ))}
58
+ </Box>
59
+ : null
60
+ }
61
+ </Box>
62
+ )
63
+ }
@@ -0,0 +1 @@
1
+ export { NativeLogList } from './native-log-list'
@@ -0,0 +1,33 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { NativeLogEvent } from '@salve-software/salvetron-types'
3
+ import { LogRow } from '../../../../../shared/components/log-row/index.js'
4
+
5
+ interface NativeLogListProps {
6
+ logs: NativeLogEvent[]
7
+ visibleRows: number
8
+ selectedIndex: number
9
+ scrollOffset: number
10
+ showHeader?: boolean
11
+ maxMessageWidth?: number
12
+ }
13
+
14
+ export function NativeLogList({ logs, visibleRows, selectedIndex, scrollOffset, showHeader = true, maxMessageWidth }: NativeLogListProps) {
15
+ const visible = logs.slice(scrollOffset, scrollOffset + visibleRows)
16
+
17
+ return (
18
+ <Box flexDirection="column">
19
+ {showHeader ? <Text color="gray" dimColor>{logs.length} native logs · ↑↓ navigate</Text> : null}
20
+ {visible.map((log, i) => {
21
+ const absoluteIndex = scrollOffset + i
22
+ const isSelected = absoluteIndex === selectedIndex
23
+ return (
24
+ <Box key={i} gap={1}>
25
+ <Text color="cyan">{isSelected ? '▶' : ' '}</Text>
26
+ <Text color="gray" dimColor>[{log.source}]</Text>
27
+ <LogRow log={log} maxMessageWidth={maxMessageWidth} />
28
+ </Box>
29
+ )
30
+ })}
31
+ </Box>
32
+ )
33
+ }
@@ -0,0 +1 @@
1
+ export { NativeLogsContainer } from './native-logs-container'