@davstack/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 (48) hide show
  1. package/README.md +66 -0
  2. package/bin/davstack.mjs +45 -0
  3. package/package.json +33 -0
  4. package/src/App.test.tsx +92 -0
  5. package/src/App.tsx +103 -0
  6. package/src/cli.ts +62 -0
  7. package/src/commands/check.test.ts +160 -0
  8. package/src/commands/check.ts +112 -0
  9. package/src/components/BottomBar.tsx +28 -0
  10. package/src/components/DaemonSupervisor.tsx +50 -0
  11. package/src/components/DescriptorSync.tsx +19 -0
  12. package/src/components/GlobalHotkeys.tsx +30 -0
  13. package/src/components/MainView.tsx +75 -0
  14. package/src/components/QuitConfirm.tsx +20 -0
  15. package/src/components/QuitController.tsx +69 -0
  16. package/src/components/StatusBar.test.tsx +47 -0
  17. package/src/components/StatusBar.tsx +67 -0
  18. package/src/hooks/useConfigDiscovery.ts +56 -0
  19. package/src/hooks/useDaemonProcess.test.ts +415 -0
  20. package/src/hooks/useDaemonProcess.ts +275 -0
  21. package/src/hooks/useHotkeys.test.tsx +508 -0
  22. package/src/hooks/useHotkeys.ts +164 -0
  23. package/src/hooks/useNoColor.test.ts +50 -0
  24. package/src/hooks/useNoColor.ts +35 -0
  25. package/src/hooks/useRingBuffer.test.tsx +86 -0
  26. package/src/hooks/useRingBuffer.ts +46 -0
  27. package/src/lib/config-discovery.test.ts +77 -0
  28. package/src/lib/config-discovery.ts +57 -0
  29. package/src/lib/daemon-registry.ts +143 -0
  30. package/src/lib/global-teardown.test.ts +75 -0
  31. package/src/lib/global-teardown.ts +78 -0
  32. package/src/lib/kill-tree.test.ts +69 -0
  33. package/src/lib/kill-tree.ts +31 -0
  34. package/src/lib/package-info.ts +35 -0
  35. package/src/lib/port-owner.test.ts +105 -0
  36. package/src/lib/port-owner.ts +90 -0
  37. package/src/lib/port-probe.test.ts +41 -0
  38. package/src/lib/port-probe.ts +29 -0
  39. package/src/lib/repo-root.test.ts +36 -0
  40. package/src/lib/repo-root.ts +30 -0
  41. package/src/lib/ring-buffer.test.ts +63 -0
  42. package/src/lib/ring-buffer.ts +47 -0
  43. package/src/state/daemons-context.tsx +149 -0
  44. package/src/state/quit-context.tsx +27 -0
  45. package/src/state/view-context.tsx +32 -0
  46. package/src/views/ServerList.test.tsx +167 -0
  47. package/src/views/ServerList.tsx +109 -0
  48. package/src/views/ServerLogView.tsx +79 -0
@@ -0,0 +1,149 @@
1
+ // Holds the live row state (status, lines, exit code) for every daemon
2
+ // plus a controls registry that maps DaemonKey -> {start, stop}.
3
+ //
4
+ // Why not just useDaemonProcess inside this provider?
5
+ // Each daemon needs its own hook call. Rules-of-hooks forbid calling
6
+ // `useDaemonProcess(d)` in a loop where `d` may vary. We keep one
7
+ // <DaemonSupervisor descriptor={d} /> child per descriptor and have it
8
+ // publish state up via `registerRow` / `registerControls`.
9
+
10
+ import React, {
11
+ createContext,
12
+ useCallback,
13
+ useContext,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ type ReactNode,
18
+ } from "react"
19
+
20
+ import type { LogLine } from "../hooks/useRingBuffer.ts"
21
+ import type { DaemonStatus } from "../hooks/useDaemonProcess.ts"
22
+ import type { DaemonDescriptor, DaemonKey } from "../lib/daemon-registry.ts"
23
+
24
+ export type DaemonRow = {
25
+ descriptor: DaemonDescriptor
26
+ status: DaemonStatus
27
+ lines: LogLine[]
28
+ exitCode?: number | null
29
+ }
30
+
31
+ export type DaemonControls = {
32
+ start: () => void
33
+ stop: () => void
34
+ takeover?: () => void
35
+ clear?: () => void
36
+ }
37
+
38
+ function makeInitialRow(descriptor: DaemonDescriptor): DaemonRow {
39
+ return { descriptor, status: "idle", lines: [], exitCode: null }
40
+ }
41
+
42
+ function useDaemonsInner(descriptors: DaemonDescriptor[]) {
43
+ const [rows, setRows] = useState<DaemonRow[]>(() => descriptors.map(makeInitialRow))
44
+ const controlsRef = useRef<Map<DaemonKey, DaemonControls>>(new Map())
45
+ const rowsRef = useRef(rows)
46
+ rowsRef.current = rows
47
+
48
+ // Keep rows aligned with descriptors when discovery resolves later.
49
+ // We do this in a layout-ish callback (the parent invokes it after the
50
+ // discovery effect resolves) rather than auto-syncing here, to keep the
51
+ // provider passive — easier to reason about in tests.
52
+ const syncDescriptors = useCallback((next: DaemonDescriptor[]) => {
53
+ setRows((prev) => {
54
+ const byKey = new Map(prev.map((r) => [r.descriptor.key, r] as const))
55
+ return next.map((d) => byKey.get(d.key) ?? makeInitialRow(d))
56
+ })
57
+ }, [])
58
+
59
+ const registerRow = useCallback((row: DaemonRow) => {
60
+ setRows((prev) => {
61
+ const idx = prev.findIndex((r) => r.descriptor.key === row.descriptor.key)
62
+ if (idx === -1) return prev
63
+ const cur = prev[idx]
64
+ // Skip no-op updates to avoid React rerender churn.
65
+ if (
66
+ cur.status === row.status &&
67
+ cur.lines === row.lines &&
68
+ cur.exitCode === row.exitCode
69
+ ) {
70
+ return prev
71
+ }
72
+ const next = prev.slice()
73
+ next[idx] = row
74
+ return next
75
+ })
76
+ }, [])
77
+
78
+ const registerControls = useCallback((key: DaemonKey, controls: DaemonControls) => {
79
+ controlsRef.current.set(key, controls)
80
+ }, [])
81
+
82
+ const toggleByKey = useCallback((key: DaemonKey) => {
83
+ const row = rowsRef.current.find((r) => r.descriptor.key === key)
84
+ if (!row) return
85
+ const controls = controlsRef.current.get(key)
86
+ if (!controls) return
87
+ if (row.status === "running" || row.status === "starting") controls.stop()
88
+ else controls.start()
89
+ }, [])
90
+
91
+ const stopAll = useCallback(() => {
92
+ for (const controls of controlsRef.current.values()) controls.stop()
93
+ }, [])
94
+
95
+ const clearByKey = useCallback((key: DaemonKey) => {
96
+ const controls = controlsRef.current.get(key)
97
+ controls?.clear?.()
98
+ }, [])
99
+
100
+ const takeoverByKey = useCallback((key: DaemonKey) => {
101
+ const row = rowsRef.current.find((r) => r.descriptor.key === key)
102
+ if (!row || row.status !== "blocked") return
103
+ const controls = controlsRef.current.get(key)
104
+ controls?.takeover?.()
105
+ }, [])
106
+
107
+ const anyLive = useCallback(() => {
108
+ return rowsRef.current.some((r) => r.status === "running" || r.status === "starting")
109
+ }, [])
110
+
111
+ return useMemo(
112
+ () => ({
113
+ rows,
114
+ rowsRef,
115
+ syncDescriptors,
116
+ registerRow,
117
+ registerControls,
118
+ toggleByKey,
119
+ stopAll,
120
+ clearByKey,
121
+ takeoverByKey,
122
+ anyLive,
123
+ }),
124
+ [rows, syncDescriptors, registerRow, registerControls, toggleByKey, stopAll, clearByKey, takeoverByKey, anyLive],
125
+ )
126
+ }
127
+
128
+ type DaemonsContextValue = ReturnType<typeof useDaemonsInner>
129
+ const DaemonsContext = createContext<DaemonsContextValue | undefined>(undefined)
130
+
131
+ export function DaemonsProvider({
132
+ descriptors,
133
+ children,
134
+ }: {
135
+ descriptors: DaemonDescriptor[]
136
+ children: ReactNode
137
+ }) {
138
+ return (
139
+ <DaemonsContext.Provider value={useDaemonsInner(descriptors)}>
140
+ {children}
141
+ </DaemonsContext.Provider>
142
+ )
143
+ }
144
+
145
+ export function useDaemons(): DaemonsContextValue {
146
+ const value = useContext(DaemonsContext)
147
+ if (!value) throw new Error("useDaemons must be used within a DaemonsProvider")
148
+ return value
149
+ }
@@ -0,0 +1,27 @@
1
+ // Tracks the "confirm-before-quit" overlay state. Kept separate from
2
+ // QuitController so the dispatcher (useHotkeys) can read `confirming`
3
+ // without depending on the cascade-shutdown wiring.
4
+
5
+ import React, { createContext, useCallback, useContext, useState, type ReactNode } from "react"
6
+
7
+ function useQuitInner() {
8
+ const [confirming, setConfirming] = useState(false)
9
+
10
+ const requestConfirm = useCallback(() => setConfirming(true), [])
11
+ const cancelConfirm = useCallback(() => setConfirming(false), [])
12
+
13
+ return { confirming, requestConfirm, cancelConfirm }
14
+ }
15
+
16
+ type QuitContextValue = ReturnType<typeof useQuitInner>
17
+ const QuitContext = createContext<QuitContextValue | undefined>(undefined)
18
+
19
+ export function QuitProvider({ children }: { children: ReactNode }) {
20
+ return <QuitContext.Provider value={useQuitInner()}>{children}</QuitContext.Provider>
21
+ }
22
+
23
+ export function useQuit(): QuitContextValue {
24
+ const value = useContext(QuitContext)
25
+ if (!value) throw new Error("useQuit must be used within a QuitProvider")
26
+ return value
27
+ }
@@ -0,0 +1,32 @@
1
+ // Tracks which view is rendered (daemon list vs a single daemon's logs)
2
+ // and which row in the list has focus. Pattern: useViewInner -> Provider
3
+ // -> useView, see docs/react/client-state-management.mdx for rationale.
4
+
5
+ import React, { createContext, useCallback, useContext, useState, type ReactNode } from "react"
6
+
7
+ import type { DaemonKey } from "../lib/daemon-registry.ts"
8
+
9
+ export type View = { kind: "list" } | { kind: "log"; key: DaemonKey }
10
+
11
+ function useViewInner() {
12
+ const [view, setView] = useState<View>({ kind: "list" })
13
+ const [focusedIdx, setFocusedIdx] = useState(0)
14
+
15
+ const showList = useCallback(() => setView({ kind: "list" }), [])
16
+ const showLog = useCallback((key: DaemonKey) => setView({ kind: "log", key }), [])
17
+
18
+ return { view, focusedIdx, setFocusedIdx, showList, showLog }
19
+ }
20
+
21
+ type ViewContextValue = ReturnType<typeof useViewInner>
22
+ const ViewContext = createContext<ViewContextValue | undefined>(undefined)
23
+
24
+ export function ViewProvider({ children }: { children: ReactNode }) {
25
+ return <ViewContext.Provider value={useViewInner()}>{children}</ViewContext.Provider>
26
+ }
27
+
28
+ export function useView(): ViewContextValue {
29
+ const value = useContext(ViewContext)
30
+ if (!value) throw new Error("useView must be used within a ViewProvider")
31
+ return value
32
+ }
@@ -0,0 +1,167 @@
1
+ // ServerList now reads from view + daemons contexts. We wrap it in test
2
+ // providers to render. The `s` toggle dispatches through the daemons
3
+ // context's `toggleByKey`, so we assert at that seam via a controls
4
+ // registry spy plumbed through a hidden helper component.
5
+
6
+ import React, { useEffect } from "react"
7
+ import { afterEach, expect, test, vi } from "vitest"
8
+ import { render } from "ink-testing-library"
9
+
10
+ import { ServerList } from "./ServerList.tsx"
11
+ import { ViewProvider } from "../state/view-context.tsx"
12
+ import { DaemonsProvider, useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
13
+ import { QuitProvider } from "../state/quit-context.tsx"
14
+ import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
15
+
16
+ function makeDescriptor(key: "logs" | "vitest" | "playwright"): DaemonDescriptor {
17
+ return {
18
+ key,
19
+ label: key,
20
+ port: 1000,
21
+ readyRegex: /listening/i,
22
+ spawn: () => {
23
+ throw new Error("not spawned in this test")
24
+ },
25
+ }
26
+ }
27
+
28
+ // Helper that publishes seeded rows + controls into the context after
29
+ // mount, mimicking what DaemonSupervisor does in production.
30
+ function Seed({
31
+ rows,
32
+ startStop,
33
+ }: {
34
+ rows: DaemonRow[]
35
+ startStop?: Record<string, { start: () => void; stop: () => void }>
36
+ }): null {
37
+ const { registerRow, registerControls } = useDaemons()
38
+ useEffect(() => {
39
+ for (const row of rows) {
40
+ registerRow(row)
41
+ if (startStop?.[row.descriptor.key]) {
42
+ registerControls(row.descriptor.key, startStop[row.descriptor.key])
43
+ }
44
+ }
45
+ }, [rows, startStop, registerRow, registerControls])
46
+ return null
47
+ }
48
+
49
+ let active: ReturnType<typeof render> | null = null
50
+ afterEach(() => {
51
+ active?.unmount()
52
+ active = null
53
+ })
54
+
55
+ test("legend advertises the new hotkeys", () => {
56
+ const descriptors = [makeDescriptor("logs")]
57
+ active = render(
58
+ <ViewProvider>
59
+ <DaemonsProvider descriptors={descriptors}>
60
+ <QuitProvider>
61
+ <ServerList />
62
+ </QuitProvider>
63
+ </DaemonsProvider>
64
+ </ViewProvider>,
65
+ )
66
+ const frame = active.lastFrame() ?? ""
67
+ expect(frame).toContain("s start/stop")
68
+ expect(frame).toContain("k takeover")
69
+ expect(frame).toContain("1-9 jump")
70
+ })
71
+
72
+ test("renders a row per descriptor with the focus marker on idx 0", () => {
73
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
74
+ active = render(
75
+ <ViewProvider>
76
+ <DaemonsProvider descriptors={descriptors}>
77
+ <QuitProvider>
78
+ <ServerList />
79
+ </QuitProvider>
80
+ </DaemonsProvider>
81
+ </ViewProvider>,
82
+ )
83
+ const frame = active.lastFrame() ?? ""
84
+ expect(frame).toContain("› ")
85
+ expect(frame).toContain("logs")
86
+ expect(frame).toContain("vitest")
87
+ })
88
+
89
+ async function tick(): Promise<void> {
90
+ await new Promise((resolve) => setTimeout(resolve, 0))
91
+ }
92
+
93
+ test("toggleByKey dispatches stop() for a running daemon", async () => {
94
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
95
+ const stopLogs = vi.fn()
96
+ const startLogs = vi.fn()
97
+
98
+ // Inline accessor to grab the daemons context value and invoke the
99
+ // toggle the same way the `s` hotkey would. This sidesteps Ink's
100
+ // raw-mode plumbing — we test the seam where the keystroke arrives.
101
+ let toggle: ((key: string) => void) | null = null
102
+ function Capture(): null {
103
+ const { toggleByKey } = useDaemons()
104
+ toggle = toggleByKey
105
+ return null
106
+ }
107
+
108
+ active = render(
109
+ <ViewProvider>
110
+ <DaemonsProvider descriptors={descriptors}>
111
+ <QuitProvider>
112
+ <Seed
113
+ rows={[
114
+ { descriptor: descriptors[0], status: "running", lines: [], exitCode: null },
115
+ { descriptor: descriptors[1], status: "idle", lines: [], exitCode: null },
116
+ ]}
117
+ startStop={{ logs: { start: startLogs, stop: stopLogs } }}
118
+ />
119
+ <Capture />
120
+ <ServerList />
121
+ </QuitProvider>
122
+ </DaemonsProvider>
123
+ </ViewProvider>,
124
+ )
125
+ await tick()
126
+
127
+ expect(toggle).not.toBeNull()
128
+ toggle!("logs")
129
+
130
+ expect(stopLogs).toHaveBeenCalledTimes(1)
131
+ expect(startLogs).not.toHaveBeenCalled()
132
+ })
133
+
134
+ test("toggleByKey dispatches start() for an idle daemon", async () => {
135
+ const descriptors = [makeDescriptor("logs")]
136
+ const stopLogs = vi.fn()
137
+ const startLogs = vi.fn()
138
+
139
+ let toggle: ((key: string) => void) | null = null
140
+ function Capture(): null {
141
+ const { toggleByKey } = useDaemons()
142
+ toggle = toggleByKey
143
+ return null
144
+ }
145
+
146
+ active = render(
147
+ <ViewProvider>
148
+ <DaemonsProvider descriptors={descriptors}>
149
+ <QuitProvider>
150
+ <Seed
151
+ rows={[
152
+ { descriptor: descriptors[0], status: "idle", lines: [], exitCode: null },
153
+ ]}
154
+ startStop={{ logs: { start: startLogs, stop: stopLogs } }}
155
+ />
156
+ <Capture />
157
+ <ServerList />
158
+ </QuitProvider>
159
+ </DaemonsProvider>
160
+ </ViewProvider>,
161
+ )
162
+ await tick()
163
+
164
+ toggle!("logs")
165
+ expect(startLogs).toHaveBeenCalledTimes(1)
166
+ expect(stopLogs).not.toHaveBeenCalled()
167
+ })
@@ -0,0 +1,109 @@
1
+ // Daemon list view. Reads rows + focus from context, owns the row-level
2
+ // hotkeys (`↑/↓`, `enter`, `s`). Global hotkeys (`1..9`, `esc`, `q`) live
3
+ // in <GlobalHotkeys>.
4
+
5
+ import React from "react"
6
+ import { Box, Text, useInput } from "ink"
7
+
8
+ import type { DaemonStatus } from "../hooks/useDaemonProcess.ts"
9
+ import { useView } from "../state/view-context.tsx"
10
+ import { useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
11
+ import { useHotkeys } from "../hooks/useHotkeys.ts"
12
+ import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
13
+
14
+ const STATUS_GLYPH: Record<DaemonStatus, string> = {
15
+ idle: "○",
16
+ starting: "◐",
17
+ running: "●",
18
+ exiting: "◐",
19
+ exited: "○",
20
+ crashed: "✗",
21
+ blocked: "⚠",
22
+ }
23
+
24
+ // Color scheme: green=running, gray=idle/exited, red=crashed, yellow=blocked.
25
+ // Transition states (starting, exiting) ride along on yellow.
26
+ const STATUS_COLOR: Record<DaemonStatus, string> = {
27
+ idle: "gray",
28
+ starting: "yellow",
29
+ running: "green",
30
+ exiting: "yellow",
31
+ exited: "gray",
32
+ crashed: "red",
33
+ blocked: "yellow",
34
+ }
35
+
36
+ export function ServerList(): React.ReactElement {
37
+ const { rows } = useDaemons()
38
+ const { focusedIdx, setFocusedIdx, showLog } = useView()
39
+ // Hotkeys hook gives us the toggle; quit isn't called here so we pass
40
+ // a noop — the global useInput owns the q routing.
41
+ const { onToggleFocused } = useHotkeys(() => {})
42
+
43
+ const rawModeSupported = process.stdin.isTTY === true
44
+ useInput(
45
+ (input, key) => {
46
+ if (rows.length === 0) return
47
+ if (key.upArrow) {
48
+ setFocusedIdx((focusedIdx - 1 + rows.length) % rows.length)
49
+ } else if (key.downArrow) {
50
+ setFocusedIdx((focusedIdx + 1) % rows.length)
51
+ } else if (key.return) {
52
+ const target = rows[focusedIdx]
53
+ if (target) showLog(target.descriptor.key)
54
+ } else if (input === "s") {
55
+ onToggleFocused()
56
+ }
57
+ },
58
+ { isActive: rawModeSupported },
59
+ )
60
+
61
+ return (
62
+ <Box flexDirection="column">
63
+ <Box marginBottom={1}>
64
+ <Text bold>Daemons</Text>
65
+ </Box>
66
+ {rows.map((row, i) => (
67
+ <DaemonListRow key={row.descriptor.key} row={row} focused={i === focusedIdx} />
68
+ ))}
69
+ <Box marginTop={1}>
70
+ <Text dimColor>
71
+ ↑/↓ focus enter drill in s start/stop k takeover 1-9 jump q quit ● running ✗ crashed ⚠ blocked
72
+ </Text>
73
+ </Box>
74
+ </Box>
75
+ )
76
+ }
77
+
78
+ function DaemonListRow({ row, focused }: { row: DaemonRow; focused: boolean }): React.ReactElement {
79
+ const noColor = useNoColor()
80
+ const last = row.lines[row.lines.length - 1]
81
+ const lastText = last ? truncate(last.text, 60) : ""
82
+ const statusLabel =
83
+ row.status === "crashed" && typeof row.exitCode === "number"
84
+ ? `crashed (exit ${row.exitCode})`
85
+ : row.status === "blocked"
86
+ ? `blocked :${row.descriptor.port}`
87
+ : row.status
88
+ const rowColor = row.status === "crashed" ? "red" : undefined
89
+ return (
90
+ <Box>
91
+ <Text color={colorOrUndef(focused ? "cyan" : undefined, noColor)}>
92
+ {focused ? "› " : " "}
93
+ </Text>
94
+ <Text color={colorOrUndef(STATUS_COLOR[row.status], noColor)}>
95
+ {STATUS_GLYPH[row.status]}
96
+ </Text>
97
+ <Text color={colorOrUndef(rowColor, noColor)}> {row.descriptor.label.padEnd(12)}</Text>
98
+ <Text color={colorOrUndef(rowColor, noColor)} dimColor={!rowColor}>
99
+ {statusLabel.padEnd(22)}
100
+ </Text>
101
+ <Text dimColor>{lastText}</Text>
102
+ </Box>
103
+ )
104
+ }
105
+
106
+ function truncate(s: string, n: number): string {
107
+ if (s.length <= n) return s
108
+ return s.slice(0, n - 1) + "…"
109
+ }
@@ -0,0 +1,79 @@
1
+ // Drill-in log view: renders the tail of a daemon's ring buffer to fill
2
+ // the visible terminal height. `esc` (handled by GlobalHotkeys) returns
3
+ // to the list; `c` clears the ring buffer.
4
+
5
+ import React from "react"
6
+ import { Box, Text, useStdout } from "ink"
7
+
8
+ import type { LogLine } from "../hooks/useRingBuffer.ts"
9
+ import type { DaemonStatus } from "../hooks/useDaemonProcess.ts"
10
+ import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
11
+ import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
12
+
13
+ interface ServerLogViewProps {
14
+ descriptor: DaemonDescriptor
15
+ status: DaemonStatus
16
+ lines: LogLine[]
17
+ exitCode?: number | null
18
+ }
19
+
20
+ // Same scheme as ServerList — keep these two tables aligned by hand
21
+ // rather than sharing a module, since their domain types differ.
22
+ const STATUS_COLOR: Record<DaemonStatus, string> = {
23
+ idle: "gray",
24
+ starting: "yellow",
25
+ running: "green",
26
+ exiting: "yellow",
27
+ exited: "gray",
28
+ crashed: "red",
29
+ blocked: "yellow",
30
+ }
31
+
32
+ export function ServerLogView({
33
+ descriptor,
34
+ status,
35
+ lines,
36
+ exitCode,
37
+ }: ServerLogViewProps): React.ReactElement {
38
+ const noColor = useNoColor()
39
+ const { stdout } = useStdout()
40
+ const rows = stdout?.rows ?? 24
41
+ // Reserve a couple of lines for header + footer.
42
+ const visible = Math.max(5, rows - 4)
43
+ const tail = lines.slice(-visible)
44
+
45
+ return (
46
+ <Box flexDirection="column">
47
+ <Box>
48
+ <Text bold>{descriptor.label}</Text>
49
+ <Text> </Text>
50
+ <Text color={colorOrUndef(STATUS_COLOR[status], noColor)}>({status})</Text>
51
+ <Text dimColor> — port {descriptor.port}</Text>
52
+ </Box>
53
+ {status === "crashed" ? (
54
+ <Box marginTop={1}>
55
+ <Text color={colorOrUndef("red", noColor)}>
56
+ daemon exited with code {exitCode ?? "?"} — last logs preserved
57
+ </Text>
58
+ </Box>
59
+ ) : null}
60
+ <Box flexDirection="column" marginTop={1}>
61
+ {tail.length === 0 ? (
62
+ <Text dimColor>(no output yet)</Text>
63
+ ) : (
64
+ tail.map((l, i) => (
65
+ // Per-line color intentionally left default. stderr is used
66
+ // by most daemons as a diagnostic channel (info/warn lines
67
+ // routed there to keep stdout clean), so painting it red
68
+ // misrepresents normal output. The status pill above is the
69
+ // signal for daemon-level health.
70
+ <Text key={i}>{l.text}</Text>
71
+ ))
72
+ )}
73
+ </Box>
74
+ <Box marginTop={1}>
75
+ <Text dimColor>esc back · c clear · q quit</Text>
76
+ </Box>
77
+ </Box>
78
+ )
79
+ }