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,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 { useNativeLogs } from '../../../store/native-logs.store.js'
7
+ import { NativeLogList } from '../../components/native-log-list/index.js'
8
+ import { NativeLogDetail } from '../../components/native-log-detail/index.js'
9
+ import { formatBody, formatPlainBody } from '../../../../../shared/utils/format-body.js'
10
+ import type { NativeLogEvent } from '@salve-software/salvetron-types'
11
+
12
+ const OVERHEAD_ROWS = 6
13
+ const DETAIL_FIXED_ROWS = 3
14
+ const MIN_LIST_ROWS = 4
15
+
16
+ export function NativeLogsContainer() {
17
+ const [cols, rows] = useTerminalSize()
18
+ const logs = useNativeLogs()
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<NativeLogEvent | 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
+ <NativeLogList
60
+ logs={logs}
61
+ visibleRows={listRows}
62
+ selectedIndex={selectedIndex}
63
+ scrollOffset={scrollOffset}
64
+ />
65
+ </Box>
66
+ {detailOpen && selectedLog
67
+ ?
68
+ <NativeLogDetail
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,15 @@
1
+ export const METHOD_COLOR: Record<string, string> = {
2
+ GET: 'green',
3
+ POST: 'cyan',
4
+ PUT: 'yellow',
5
+ PATCH: 'yellow',
6
+ DELETE: 'red',
7
+ HEAD: 'gray',
8
+ }
9
+
10
+ export function getStatusColor(statusCode: number | null): string {
11
+ if (!statusCode) return 'gray'
12
+ if (statusCode >= 500) return 'red'
13
+ if (statusCode >= 400) return 'yellow'
14
+ return 'green'
15
+ }
@@ -0,0 +1,58 @@
1
+ import { create } from 'zustand'
2
+ import { useShallow } from 'zustand/react/shallow'
3
+ import type { NetworkEvent, NetworkLog } from '@salve-software/salvetron-types'
4
+ import { normalizeHttpMethod } from '@salve-software/salvetron-types'
5
+
6
+ interface NetworkStore {
7
+ logsArray: NetworkLog[]
8
+ addOrUpdateLog: (event: NetworkEvent) => void
9
+ clear: () => void
10
+ }
11
+
12
+ const MAX = 100
13
+
14
+ export const useNetworkStore = create<NetworkStore>((set) => ({
15
+ logsArray: [],
16
+ addOrUpdateLog: (event) =>
17
+ set((s) => {
18
+ if (event.stage === 'request') {
19
+ const entry: NetworkLog = {
20
+ requestId: event.requestId,
21
+ deviceId: event.deviceId ?? 'unknown',
22
+ projectId: event.projectId,
23
+ method: normalizeHttpMethod(event.method),
24
+ url: event.url,
25
+ requestHeaders: event.headers ?? {},
26
+ requestBody: event.body ?? null,
27
+ requestTimestamp: event.timestamp,
28
+ statusCode: null,
29
+ responseHeaders: null,
30
+ responseBody: null,
31
+ responseTimestamp: null,
32
+ duration: null,
33
+ state: 'pending',
34
+ }
35
+ return { logsArray: [...s.logsArray, entry].slice(-MAX) }
36
+ }
37
+
38
+ const idx = s.logsArray.findIndex((l) => l.requestId === event.requestId)
39
+ if (idx === -1) return s
40
+
41
+ const updated = [...s.logsArray]
42
+ updated[idx] = {
43
+ ...updated[idx],
44
+ statusCode: event.statusCode,
45
+ responseHeaders: event.headers ?? null,
46
+ responseBody: event.body ?? null,
47
+ responseTimestamp: event.timestamp,
48
+ duration: event.duration,
49
+ state: 'completed',
50
+ }
51
+ return { logsArray: updated }
52
+ }),
53
+ clear: () => set({ logsArray: [] }),
54
+ }))
55
+
56
+ export const useNetworkLogs = () => useNetworkStore(useShallow((s) => s.logsArray))
57
+ export const useRecentNetworkLogs = (n: number) =>
58
+ useNetworkStore(useShallow((s) => s.logsArray.slice(-n)))
@@ -0,0 +1 @@
1
+ export { NetworkDetail } from './network-detail'
@@ -0,0 +1,82 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { NetworkLog } from '@salve-software/salvetron-types'
3
+ import { METHOD_COLOR, getStatusColor } from '../../../library/constants.js'
4
+ import type { CopyFeedback } from '../../../../../shared/hooks/use-detail-panel.js'
5
+
6
+ interface NetworkDetailProps {
7
+ log: NetworkLog
8
+ width: number
9
+ bodyLines: string[]
10
+ bodyScrollOffset: number
11
+ bodyVisibleRows: number
12
+ copyFeedback?: CopyFeedback | null
13
+ }
14
+
15
+ export function NetworkDetail({ log, width, bodyLines, bodyScrollOffset, bodyVisibleRows, copyFeedback }: NetworkDetailProps) {
16
+ const time = new Date(log.requestTimestamp).toLocaleTimeString('en', { hour12: false })
17
+ const reqHeaders = Object.entries(log.requestHeaders ?? {})
18
+ const resHeaders = Object.entries(log.responseHeaders ?? {})
19
+ const visibleLines = bodyLines.slice(bodyScrollOffset, bodyScrollOffset + bodyVisibleRows)
20
+ const canScroll = bodyLines.length > bodyVisibleRows
21
+
22
+ return (
23
+ <Box
24
+ flexDirection="column"
25
+ borderStyle="single"
26
+ borderColor="gray"
27
+ borderTop={true}
28
+ borderBottom={false}
29
+ borderLeft={false}
30
+ borderRight={false}
31
+ paddingX={1}
32
+ >
33
+ <Box gap={2}>
34
+ <Text color={METHOD_COLOR[log.method] ?? 'white'} bold>{log.method}</Text>
35
+ <Text color={getStatusColor(log.statusCode)} bold>
36
+ {log.statusCode ?? 'pending'}
37
+ </Text>
38
+ {log.duration ? <Text color="gray">{log.duration}ms</Text> : null}
39
+ <Text color="gray" dimColor>{time}</Text>
40
+ {copyFeedback
41
+ ?
42
+ <Text color={copyFeedback.success ? 'green' : 'red'}>
43
+ {copyFeedback.success
44
+ ? (copyFeedback.kind === 'extra' ? '✓ curl copied' : '✓ Copied')
45
+ : '✗ Copy failed'}
46
+ </Text>
47
+ : null
48
+ }
49
+ </Box>
50
+ <Text color="whiteBright">{log.url.slice(0, width - 2)}</Text>
51
+ {reqHeaders.length > 0
52
+ ?
53
+ <Text color="whiteBright" dimColor>
54
+ req: {reqHeaders.slice(0, 3).map(([k, v]) => `${k}: ${v}`).join(' ')}
55
+ </Text>
56
+ : null
57
+ }
58
+ {resHeaders.length > 0
59
+ ?
60
+ <Text color="whiteBright" dimColor>
61
+ res: {resHeaders.slice(0, 3).map(([k, v]) => `${k}: ${v}`).join(' ')}
62
+ </Text>
63
+ : null
64
+ }
65
+ {bodyLines.length > 0
66
+ ?
67
+ <Box flexDirection="column">
68
+ <Text color="whiteBright" dimColor>
69
+ {'── body'}
70
+ {canScroll ? ` [ = scroll up ] = scroll down · line ${bodyScrollOffset + 1} of ${bodyLines.length}` : ''}
71
+ {' · c copy · u curl'}
72
+ {' ──'}
73
+ </Text>
74
+ {visibleLines.map((line, i) => (
75
+ <Text key={i}>{line}</Text>
76
+ ))}
77
+ </Box>
78
+ : null
79
+ }
80
+ </Box>
81
+ )
82
+ }
@@ -0,0 +1 @@
1
+ export { NetworkRow } from './network-row'
@@ -0,0 +1,23 @@
1
+ import { Box, Text } from 'ink'
2
+ import type { NetworkLog } from '@salve-software/salvetron-types'
3
+ import { METHOD_COLOR, getStatusColor } from '../../../library/constants.js'
4
+
5
+ interface NetworkRowProps {
6
+ log: NetworkLog
7
+ urlMaxWidth: number
8
+ isSelected?: boolean
9
+ }
10
+
11
+ export function NetworkRow({ log, urlMaxWidth, isSelected = false }: NetworkRowProps) {
12
+ const dur = log.duration ? `${log.duration}ms` : '...'
13
+
14
+ return (
15
+ <Box gap={1}>
16
+ <Text color="cyan">{isSelected ? '▶' : ' '}</Text>
17
+ <Text color={METHOD_COLOR[log.method] ?? 'white'}>{log.method.padEnd(8)}</Text>
18
+ <Text color={getStatusColor(log.statusCode)}>{String(log.statusCode ?? '---').padEnd(7)}</Text>
19
+ <Text color="gray">{dur.padEnd(8)}</Text>
20
+ <Text>{log.url.slice(0, urlMaxWidth)}</Text>
21
+ </Box>
22
+ )
23
+ }
@@ -0,0 +1 @@
1
+ export { NetworkTableHeader } from './network-table-header'
@@ -0,0 +1,12 @@
1
+ import { Box, Text } from 'ink'
2
+
3
+ export function NetworkTableHeader() {
4
+ return (
5
+ <Box gap={1}>
6
+ <Text bold color="gray">{'METHOD'.padEnd(8)}</Text>
7
+ <Text bold color="gray">{'STATUS'.padEnd(7)}</Text>
8
+ <Text bold color="gray">{'DUR'.padEnd(8)}</Text>
9
+ <Text bold color="gray">URL</Text>
10
+ </Box>
11
+ )
12
+ }
@@ -0,0 +1 @@
1
+ export { NetworkContainer } from './network-container'
@@ -0,0 +1,93 @@
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 { useNetworkLogs } from '../../../store/network.store.js'
7
+ import { NetworkTableHeader } from '../../components/network-table-header/index.js'
8
+ import { NetworkRow } from '../../components/network-row/index.js'
9
+ import { NetworkDetail } from '../../components/network-detail/index.js'
10
+ import { formatBody, formatPlainBody } from '../../../../../shared/utils/format-body.js'
11
+ import { buildCurlCommand } from '../../../../../shared/utils/build-curl-command.js'
12
+ import type { NetworkLog } from '@salve-software/salvetron-types'
13
+
14
+ const OVERHEAD_ROWS = 6
15
+ const DETAIL_FIXED_ROWS = 5
16
+ const MIN_LIST_ROWS = 4
17
+
18
+ export function NetworkContainer() {
19
+ const [cols, rows] = useTerminalSize()
20
+ const logs = useNetworkLogs()
21
+
22
+ const availableRows = rows - OVERHEAD_ROWS
23
+ const detailHeight = Math.max(DETAIL_FIXED_ROWS + 2, availableRows - MIN_LIST_ROWS)
24
+ const bodyVisibleRows = detailHeight - DETAIL_FIXED_ROWS
25
+
26
+ const bodyLinesRef = useRef<string[]>([])
27
+ const selectedLogRef = useRef<NetworkLog | null>(null)
28
+
29
+ const onCopyBody = useCallback(() => {
30
+ const log = selectedLogRef.current
31
+ return log ? formatPlainBody(log.responseBody) : ''
32
+ }, [])
33
+ const onCopyExtra = useCallback(() => {
34
+ const log = selectedLogRef.current
35
+ return log ? buildCurlCommand(log) : ''
36
+ }, [])
37
+
38
+ const { detailOpen, detailScrollOffset, resetDetailScroll, copyFeedback } = useDetailPanel({
39
+ linesRef: bodyLinesRef,
40
+ visibleRows: bodyVisibleRows,
41
+ scrollStep: 5,
42
+ onCopyBody,
43
+ onCopyExtra,
44
+ })
45
+
46
+ const listRows = detailOpen
47
+ ? Math.max(MIN_LIST_ROWS, availableRows - detailHeight)
48
+ : availableRows
49
+
50
+ const { selectedIndex, scrollOffset } = useListNavigation({ count: logs.length, visibleRows: listRows })
51
+
52
+ const selectedLog = logs[selectedIndex] ?? null
53
+ const bodyLines = formatBody(selectedLog?.responseBody)
54
+ bodyLinesRef.current = bodyLines
55
+ selectedLogRef.current = selectedLog
56
+
57
+ useEffect(() => {
58
+ resetDetailScroll()
59
+ }, [selectedIndex, resetDetailScroll])
60
+
61
+ const visible = logs.slice(scrollOffset, scrollOffset + listRows)
62
+
63
+ return (
64
+ <Box flexDirection="column">
65
+ <Box flexGrow={1} flexDirection="column">
66
+ <NetworkTableHeader />
67
+ {visible.map((log, i) => {
68
+ const absoluteIndex = scrollOffset + i
69
+ return (
70
+ <NetworkRow
71
+ key={log.requestId}
72
+ log={log}
73
+ urlMaxWidth={cols - 30}
74
+ isSelected={absoluteIndex === selectedIndex}
75
+ />
76
+ )
77
+ })}
78
+ </Box>
79
+ {detailOpen && selectedLog
80
+ ?
81
+ <NetworkDetail
82
+ log={selectedLog}
83
+ width={cols}
84
+ bodyLines={bodyLines}
85
+ bodyScrollOffset={detailScrollOffset}
86
+ bodyVisibleRows={bodyVisibleRows}
87
+ copyFeedback={copyFeedback}
88
+ />
89
+ : null
90
+ }
91
+ </Box>
92
+ )
93
+ }
@@ -0,0 +1,52 @@
1
+ import { WebSocketServer } from 'ws'
2
+ import type { RnTuiEvent } from '@salve-software/salvetron-types'
3
+ import { useJsLogsStore } from '../modules/js-logs/store/js-logs.store.js'
4
+ import { useNativeLogsStore } from '../modules/native-logs/store/native-logs.store.js'
5
+ import { useNetworkStore } from '../modules/network/store/network.store.js'
6
+ import { useDashboardStore } from '../modules/dashboard/store/dashboard.store.js'
7
+ import { useDeviceStore } from '../shared/store/device.store.js'
8
+
9
+ export function startWsServer(port: number) {
10
+ const wss = new WebSocketServer({ port, host: '0.0.0.0' })
11
+
12
+ wss.on('error', (err: NodeJS.ErrnoException) => {
13
+ if (err.code === 'EADDRINUSE') {
14
+ console.error(`Port ${port} already in use. Set SALVETRON_PORT env var to change port.`)
15
+ process.exit(1)
16
+ }
17
+ })
18
+
19
+ wss.on('connection', (socket) => {
20
+ socket.on('message', (raw) => {
21
+ try {
22
+ const event: RnTuiEvent = JSON.parse(raw.toString())
23
+ dispatch(event)
24
+ } catch {}
25
+ })
26
+
27
+ socket.on('close', () => {
28
+ useDeviceStore.getState().setDisconnected()
29
+ })
30
+ })
31
+ }
32
+
33
+ function dispatch(event: RnTuiEvent) {
34
+ switch (event.type) {
35
+ case 'device_info':
36
+ case 'project_info':
37
+ useDeviceStore.getState().setInfo(event)
38
+ break
39
+ case 'log':
40
+ useJsLogsStore.getState().addLog(event)
41
+ break
42
+ case 'native':
43
+ useNativeLogsStore.getState().addLog(event)
44
+ break
45
+ case 'network':
46
+ useNetworkStore.getState().addOrUpdateLog(event)
47
+ break
48
+ case 'performance_metrics':
49
+ useDashboardStore.getState().addSnapshot(event)
50
+ break
51
+ }
52
+ }
@@ -0,0 +1,169 @@
1
+ import { Text, useStdout } from "ink";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import figlet from "figlet";
4
+
5
+ const FALLBACK_TEXT = "RN PANEL";
6
+ const MAX_TEXT_LENGTH = 18;
7
+
8
+ const DURATION_MS = 4000;
9
+ const TICK_MS = 80;
10
+ const TWO_PI = Math.PI * 2;
11
+
12
+ // Glyphs are never swapped for block characters anymore — only color
13
+ // brightness is modulated, so the full name stays legible at every frame,
14
+ // including loop edges. The wave crest lightens the base color toward a
15
+ // slightly lighter shade (not pure white) instead of dimming it, for a
16
+ // glow/shine sweep.
17
+ const GLOW_MIX = 0.65;
18
+ const GLOW_LEVELS = 55;
19
+
20
+ export const LOGO_COLOR_PALETTE = [
21
+ "#61DAFB", // React cyan (original brand color)
22
+ "#FF6B6B", // coral red
23
+ "#4ECDC4", // teal
24
+ "#FFD93D", // gold
25
+ "#A78BFA", // violet
26
+ "#FF8C42", // orange
27
+ "#52D17C", // emerald green
28
+ "#F472B6", // pink
29
+ "#38BDF8", // sky blue
30
+ "#FACC15", // amber
31
+ ] as const;
32
+
33
+ export function pickRandomColor(): string {
34
+ return LOGO_COLOR_PALETTE[Math.floor(Math.random() * LOGO_COLOR_PALETTE.length)];
35
+ }
36
+
37
+ type Rgb = [number, number, number];
38
+
39
+ function hexToRgb(hex: string): Rgb {
40
+ const value = hex.replace("#", "");
41
+ const r = parseInt(value.slice(0, 2), 16);
42
+ const g = parseInt(value.slice(2, 4), 16);
43
+ const b = parseInt(value.slice(4, 6), 16);
44
+ return [r, g, b];
45
+ }
46
+
47
+ function lerp(a: number, b: number, t: number): number {
48
+ return Math.round(a + (b - a) * t);
49
+ }
50
+
51
+ const RESET = "";
52
+
53
+ function ansiColor([r, g, b]: Rgb): string {
54
+ return `[38;2;${r};${g};${b}m`;
55
+ }
56
+
57
+ function buildArt(text: string) {
58
+ // Truncated so longer project names don't blow past typical terminal
59
+ // width and wrap mid-glyph.
60
+ const safeText = text.slice(0, MAX_TEXT_LENGTH);
61
+ const art = figlet
62
+ .textSync(safeText, { font: "ANSI Shadow" })
63
+ .split("\n")
64
+ .filter((line) => line.trim().length > 0);
65
+
66
+ // Guard against an empty `art` (e.g. blank input) collapsing Math.max to
67
+ // -Infinity, which would propagate NaN into the wave frequency below.
68
+ const maxWidth = Math.max(1, ...art.map((line) => line.length));
69
+
70
+ // Several ripples across the full width so the wave never dims everything
71
+ // at once (a single period close to maxWidth caused a visible gap
72
+ // mid-animation).
73
+ const waveFrequency = (TWO_PI * 3) / maxWidth;
74
+
75
+ return { art, waveFrequency };
76
+ }
77
+
78
+ function render(t: number, rgb: Rgb, art: string[], waveFrequency: number): string {
79
+ const [r, g, b] = rgb;
80
+ const peak: Rgb = [
81
+ lerp(r, 255, GLOW_MIX),
82
+ lerp(g, 255, GLOW_MIX),
83
+ lerp(b, 255, GLOW_MIX),
84
+ ];
85
+
86
+ const lines: string[] = [];
87
+ for (let y = 0; y < art.length; y++) {
88
+ const row = art[y];
89
+ let line = "";
90
+ let lastLevel = -1;
91
+ for (let x = 0; x < row.length; x++) {
92
+ const ch = row[x];
93
+ if (ch === " ") {
94
+ line += " ";
95
+ continue;
96
+ }
97
+ const wave = Math.sin(x * waveFrequency + t) * 0.5 + 0.5;
98
+ const verticalWave = Math.sin(y * 0.5 + t * 0.8) * 0.5 + 0.5;
99
+ const glow = wave * 0.6 + verticalWave * 0.4;
100
+ const level = Math.round(glow * GLOW_LEVELS);
101
+ if (level !== lastLevel) {
102
+ const factor = level / GLOW_LEVELS;
103
+ line += ansiColor([
104
+ lerp(r, peak[0], factor),
105
+ lerp(g, peak[1], factor),
106
+ lerp(b, peak[2], factor),
107
+ ]);
108
+ lastLevel = level;
109
+ }
110
+ line += ch;
111
+ }
112
+ line += RESET;
113
+ lines.push(line);
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+
118
+ interface AsciiLogoProps {
119
+ text?: string;
120
+ color?: string;
121
+ }
122
+
123
+ export function AsciiLogo({ text = FALLBACK_TEXT, color = "#61DAFB" }: AsciiLogoProps) {
124
+ const upperText = text.toUpperCase();
125
+ const { art, waveFrequency } = useMemo(() => buildArt(upperText), [upperText]);
126
+ const rgb = useMemo(() => hexToRgb(color), [color]);
127
+ const [frame, setFrame] = useState(() => render(0, rgb, art, waveFrequency));
128
+ const { stdout } = useStdout();
129
+ const isResizingRef = useRef(false);
130
+
131
+ useEffect(() => {
132
+ if (!stdout) return;
133
+ let timer: ReturnType<typeof setTimeout> | undefined;
134
+ const handler = () => {
135
+ isResizingRef.current = true;
136
+ clearTimeout(timer);
137
+ timer = setTimeout(() => {
138
+ isResizingRef.current = false;
139
+ }, 150);
140
+ };
141
+ stdout.on("resize", handler);
142
+ return () => {
143
+ clearTimeout(timer);
144
+ stdout.off("resize", handler);
145
+ };
146
+ }, [stdout]);
147
+
148
+ useEffect(() => {
149
+ const start = Date.now();
150
+ // Render immediately so a text/color change doesn't leave a stale frame
151
+ // on screen until the next tick.
152
+ setFrame(render(0, rgb, art, waveFrequency));
153
+
154
+ const id = setInterval(() => {
155
+ if (isResizingRef.current) return;
156
+ const pos = (Date.now() - start) % (DURATION_MS * 2);
157
+ const linear =
158
+ pos < DURATION_MS
159
+ ? pos / DURATION_MS
160
+ : 1 - (pos - DURATION_MS) / DURATION_MS;
161
+ const eased = linear * linear * (3 - 2 * linear);
162
+ setFrame(render(eased * TWO_PI, rgb, art, waveFrequency));
163
+ }, TICK_MS);
164
+
165
+ return () => clearInterval(id);
166
+ }, [art, waveFrequency, rgb]);
167
+
168
+ return <Text>{frame}</Text>;
169
+ }
File without changes
@@ -0,0 +1 @@
1
+ export { AsciiLogo, pickRandomColor } from './ascii-logo'
@@ -0,0 +1,18 @@
1
+ import { Text } from 'ink'
2
+
3
+ interface GaugeProps {
4
+ value: number
5
+ max: number
6
+ width?: number
7
+ warnAt?: number
8
+ critAt?: number
9
+ }
10
+
11
+ export function Gauge({ value, max, width = 12, warnAt = 0.7, critAt = 0.9 }: GaugeProps) {
12
+ const ratio = Math.min(1, Math.max(0, value / max))
13
+ const filled = Math.round(ratio * width)
14
+ const bar = ':'.repeat(filled) + '.'.repeat(width - filled)
15
+ const color = ratio >= critAt ? 'red' : ratio >= warnAt ? 'yellow' : 'green'
16
+
17
+ return <Text color={color}>{bar}</Text>
18
+ }
@@ -0,0 +1 @@
1
+ export { Gauge } from './gauge'
@@ -0,0 +1 @@
1
+ export { LogRow } from './log-row'
@@ -0,0 +1,28 @@
1
+ import { Text } from 'ink'
2
+ import type { AnyLog } from '../../types.js'
3
+
4
+ const LEVEL_COLOR: Record<string, string> = {
5
+ error: 'red',
6
+ warn: 'yellow',
7
+ info: 'cyan',
8
+ debug: 'gray',
9
+ log: 'white',
10
+ }
11
+
12
+ interface LogRowProps {
13
+ log: AnyLog
14
+ maxMessageWidth?: number
15
+ }
16
+
17
+ export function LogRow({ log, maxMessageWidth = 120 }: LogRowProps) {
18
+ const color = LEVEL_COLOR[log.level] ?? 'white'
19
+ const time = new Date(log.timestamp).toLocaleTimeString('en', { hour12: false })
20
+
21
+ return (
22
+ <Text>
23
+ <Text color="gray">{time} </Text>
24
+ <Text color={color}>[{log.level.toUpperCase().padEnd(5)}] </Text>
25
+ <Text>{log.message.slice(0, maxMessageWidth)}</Text>
26
+ </Text>
27
+ )
28
+ }
@@ -0,0 +1 @@
1
+ export { Panel } from './panel'