@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.
- package/README.md +66 -0
- package/bin/davstack.mjs +45 -0
- package/package.json +33 -0
- package/src/App.test.tsx +92 -0
- package/src/App.tsx +103 -0
- package/src/cli.ts +62 -0
- package/src/commands/check.test.ts +160 -0
- package/src/commands/check.ts +112 -0
- package/src/components/BottomBar.tsx +28 -0
- package/src/components/DaemonSupervisor.tsx +50 -0
- package/src/components/DescriptorSync.tsx +19 -0
- package/src/components/GlobalHotkeys.tsx +30 -0
- package/src/components/MainView.tsx +75 -0
- package/src/components/QuitConfirm.tsx +20 -0
- package/src/components/QuitController.tsx +69 -0
- package/src/components/StatusBar.test.tsx +47 -0
- package/src/components/StatusBar.tsx +67 -0
- package/src/hooks/useConfigDiscovery.ts +56 -0
- package/src/hooks/useDaemonProcess.test.ts +415 -0
- package/src/hooks/useDaemonProcess.ts +275 -0
- package/src/hooks/useHotkeys.test.tsx +508 -0
- package/src/hooks/useHotkeys.ts +164 -0
- package/src/hooks/useNoColor.test.ts +50 -0
- package/src/hooks/useNoColor.ts +35 -0
- package/src/hooks/useRingBuffer.test.tsx +86 -0
- package/src/hooks/useRingBuffer.ts +46 -0
- package/src/lib/config-discovery.test.ts +77 -0
- package/src/lib/config-discovery.ts +57 -0
- package/src/lib/daemon-registry.ts +143 -0
- package/src/lib/global-teardown.test.ts +75 -0
- package/src/lib/global-teardown.ts +78 -0
- package/src/lib/kill-tree.test.ts +69 -0
- package/src/lib/kill-tree.ts +31 -0
- package/src/lib/package-info.ts +35 -0
- package/src/lib/port-owner.test.ts +105 -0
- package/src/lib/port-owner.ts +90 -0
- package/src/lib/port-probe.test.ts +41 -0
- package/src/lib/port-probe.ts +29 -0
- package/src/lib/repo-root.test.ts +36 -0
- package/src/lib/repo-root.ts +30 -0
- package/src/lib/ring-buffer.test.ts +63 -0
- package/src/lib/ring-buffer.ts +47 -0
- package/src/state/daemons-context.tsx +149 -0
- package/src/state/quit-context.tsx +27 -0
- package/src/state/view-context.tsx +32 -0
- package/src/views/ServerList.test.tsx +167 -0
- package/src/views/ServerList.tsx +109 -0
- 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
|
+
}
|