@davstack/tui 0.2.0 → 0.3.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/package.json +6 -3
- package/src/App.tsx +5 -2
- package/src/cli.ts +25 -5
- package/src/components/BottomBar.tsx +4 -3
- package/src/components/ControlsHint.tsx +19 -0
- package/src/components/MainView.tsx +8 -0
- package/src/components/Markdown.tsx +130 -0
- package/src/components/StatusBar.tsx +34 -2
- package/src/hooks/useAdapterFor.ts +10 -0
- package/src/hooks/useAgentJobs.test.tsx +149 -0
- package/src/hooks/useAgentJobs.ts +53 -0
- package/src/hooks/useAgentTimeline.ts +176 -0
- package/src/hooks/useHotkeys.test.tsx +8 -5
- package/src/hooks/useHotkeys.ts +69 -10
- package/src/lib/agent-glyphs.ts +52 -0
- package/src/lib/agent-pill.ts +25 -0
- package/src/lib/agent-title.test.ts +42 -0
- package/src/lib/agent-title.ts +29 -0
- package/src/lib/format-agent-timeline.test.ts +142 -0
- package/src/lib/format-agent-timeline.ts +224 -0
- package/src/lib/read-agent-diff.ts +27 -0
- package/src/lib/read-agent-overlay.ts +13 -0
- package/src/state/agents-context.tsx +59 -0
- package/src/state/view-context.tsx +20 -3
- package/src/views/AgentTimelineView.tsx +252 -0
- package/src/views/AgentsList.tsx +131 -0
- package/src/views/ServerList.test.tsx +28 -20
- package/src/views/ServerList.tsx +12 -6
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react"
|
|
2
|
+
import { Box, Text, useInput } from "ink"
|
|
3
|
+
|
|
4
|
+
import type { JobRecord, JobStatus } from "@davstack/open-agents/core/jobs"
|
|
5
|
+
import { useAgentJobs } from "../hooks/useAgentJobs.ts"
|
|
6
|
+
import { getRepoRootSafe } from "../lib/package-info.ts"
|
|
7
|
+
import { jobStatusGlyph } from "../lib/agent-glyphs.ts"
|
|
8
|
+
import { inferAgentTitle } from "../lib/agent-title.ts"
|
|
9
|
+
import { useView } from "../state/view-context.tsx"
|
|
10
|
+
import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
|
|
11
|
+
import { ControlsHint } from "../components/ControlsHint.tsx"
|
|
12
|
+
|
|
13
|
+
const AGENTS_CONTROLS =
|
|
14
|
+
"↑/↓ j/k focus enter drill in esc back g agents q quit " +
|
|
15
|
+
"● running ✗ failed ○ done"
|
|
16
|
+
|
|
17
|
+
const STATUS_LABEL: Record<JobStatus, string> = {
|
|
18
|
+
running: "run",
|
|
19
|
+
done: "done",
|
|
20
|
+
failed: "fail",
|
|
21
|
+
cancelled: "cancel",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STATUS_COLOR: Record<JobStatus, string> = {
|
|
25
|
+
running: "yellow",
|
|
26
|
+
done: "gray",
|
|
27
|
+
failed: "red",
|
|
28
|
+
cancelled: "gray",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AgentsList(): React.ReactElement {
|
|
32
|
+
const repoPath = getRepoRootSafe()
|
|
33
|
+
const { jobs } = useAgentJobs(repoPath)
|
|
34
|
+
const { focusedIdx, setFocusedIdx, showAgent } = useView()
|
|
35
|
+
const [controlsOpen, setControlsOpen] = useState(false)
|
|
36
|
+
|
|
37
|
+
const rawModeSupported = process.stdin.isTTY === true
|
|
38
|
+
useInput(
|
|
39
|
+
(input, key) => {
|
|
40
|
+
if (input === "c") {
|
|
41
|
+
setControlsOpen((v) => !v)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (jobs.length === 0) return
|
|
45
|
+
if (key.upArrow || input === "k") {
|
|
46
|
+
setFocusedIdx((focusedIdx - 1 + jobs.length) % jobs.length)
|
|
47
|
+
} else if (key.downArrow || input === "j") {
|
|
48
|
+
setFocusedIdx((focusedIdx + 1) % jobs.length)
|
|
49
|
+
} else if (key.return) {
|
|
50
|
+
const job = jobs[Math.min(focusedIdx, jobs.length - 1)]
|
|
51
|
+
if (job) showAgent(job.id)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ isActive: rawModeSupported },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (jobs.length === 0) {
|
|
58
|
+
return <AgentsEmptyState controlsOpen={controlsOpen} />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const safeFocus = Math.min(focusedIdx, jobs.length - 1)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box flexDirection="column">
|
|
65
|
+
{jobs.map((job, i) => (
|
|
66
|
+
<AgentListRow key={job.id} job={job} focused={i === safeFocus} />
|
|
67
|
+
))}
|
|
68
|
+
<ControlsHint expanded={controlsOpen} controls={AGENTS_CONTROLS} />
|
|
69
|
+
</Box>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function AgentListRow({ job, focused }: { job: JobRecord; focused: boolean }): React.ReactElement {
|
|
74
|
+
const noColor = useNoColor()
|
|
75
|
+
const title = useMemo(() => truncateOneLine(inferAgentTitle({ prompt: job.prompt }), 80), [job.prompt])
|
|
76
|
+
const when = formatWhen(job.startedAt)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Box>
|
|
80
|
+
<Text color={colorOrUndef(focused ? "cyan" : undefined, noColor)}>
|
|
81
|
+
{focused ? "› " : " "}
|
|
82
|
+
</Text>
|
|
83
|
+
<Text color={colorOrUndef(STATUS_COLOR[job.status], noColor)}>
|
|
84
|
+
{jobStatusGlyph(job.status, noColor)}
|
|
85
|
+
</Text>
|
|
86
|
+
<Text> {STATUS_LABEL[job.status].padEnd(7)}</Text>
|
|
87
|
+
<Text bold>{title.padEnd(82)}</Text>
|
|
88
|
+
<Text dimColor>{when}</Text>
|
|
89
|
+
</Box>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function AgentsEmptyState({ controlsOpen }: { controlsOpen: boolean }): React.ReactElement {
|
|
94
|
+
return (
|
|
95
|
+
<Box flexDirection="column">
|
|
96
|
+
<Text dimColor>
|
|
97
|
+
No agents have run in this repo yet. Submit one with: explore "<prompt>"
|
|
98
|
+
</Text>
|
|
99
|
+
<ControlsHint expanded={controlsOpen} controls={AGENTS_CONTROLS} />
|
|
100
|
+
</Box>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatWhen(startedAt: string, now = Date.now()): string {
|
|
105
|
+
const then = new Date(startedAt).getTime()
|
|
106
|
+
const ageMs = now - then
|
|
107
|
+
const dayMs = 24 * 60 * 60 * 1000
|
|
108
|
+
if (ageMs < dayMs) {
|
|
109
|
+
const ageMin = Math.max(0, Math.round(ageMs / 60000))
|
|
110
|
+
if (ageMin < 1) return "just now"
|
|
111
|
+
if (ageMin < 60) return `${ageMin}m ago`
|
|
112
|
+
const ageHr = Math.round(ageMin / 60)
|
|
113
|
+
return `${ageHr}h ago`
|
|
114
|
+
}
|
|
115
|
+
const d = new Date(then)
|
|
116
|
+
return `${ordinal(d.getDate())} ${MONTHS[d.getMonth()]}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
120
|
+
|
|
121
|
+
function ordinal(n: number): string {
|
|
122
|
+
const s = ["th", "st", "nd", "rd"]
|
|
123
|
+
const v = n % 100
|
|
124
|
+
return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function truncateOneLine(s: string, n: number): string {
|
|
128
|
+
const one = s.replace(/\s+/g, " ").trim()
|
|
129
|
+
if (one.length <= n) return one
|
|
130
|
+
return one.slice(0, n - 1) + "…"
|
|
131
|
+
}
|
|
@@ -10,6 +10,7 @@ import { render } from "ink-testing-library"
|
|
|
10
10
|
import { ServerList } from "./ServerList.tsx"
|
|
11
11
|
import { ViewProvider } from "../state/view-context.tsx"
|
|
12
12
|
import { DaemonsProvider, useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
|
|
13
|
+
import { AgentsProvider } from "../state/agents-context.tsx"
|
|
13
14
|
import { QuitProvider } from "../state/quit-context.tsx"
|
|
14
15
|
import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
|
|
15
16
|
|
|
@@ -52,32 +53,35 @@ afterEach(() => {
|
|
|
52
53
|
active = null
|
|
53
54
|
})
|
|
54
55
|
|
|
55
|
-
test("
|
|
56
|
+
test("controls hint is collapsed by default", () => {
|
|
56
57
|
const descriptors = [makeDescriptor("logs")]
|
|
57
58
|
active = render(
|
|
58
59
|
<ViewProvider>
|
|
59
|
-
<
|
|
60
|
-
<
|
|
60
|
+
<AgentsProvider>
|
|
61
|
+
<DaemonsProvider descriptors={descriptors}>
|
|
62
|
+
<QuitProvider>
|
|
61
63
|
<ServerList />
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
</QuitProvider>
|
|
65
|
+
</DaemonsProvider>
|
|
66
|
+
</AgentsProvider>
|
|
64
67
|
</ViewProvider>,
|
|
65
68
|
)
|
|
66
69
|
const frame = active.lastFrame() ?? ""
|
|
67
|
-
expect(frame).toContain("
|
|
68
|
-
expect(frame).toContain("
|
|
69
|
-
expect(frame).toContain("1-9 jump")
|
|
70
|
+
expect(frame).toContain("c for controls")
|
|
71
|
+
expect(frame).not.toContain("s start/stop")
|
|
70
72
|
})
|
|
71
73
|
|
|
72
74
|
test("renders a row per descriptor with the focus marker on idx 0", () => {
|
|
73
75
|
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
|
|
74
76
|
active = render(
|
|
75
77
|
<ViewProvider>
|
|
76
|
-
<
|
|
77
|
-
<
|
|
78
|
+
<AgentsProvider>
|
|
79
|
+
<DaemonsProvider descriptors={descriptors}>
|
|
80
|
+
<QuitProvider>
|
|
78
81
|
<ServerList />
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
</QuitProvider>
|
|
83
|
+
</DaemonsProvider>
|
|
84
|
+
</AgentsProvider>
|
|
81
85
|
</ViewProvider>,
|
|
82
86
|
)
|
|
83
87
|
const frame = active.lastFrame() ?? ""
|
|
@@ -107,8 +111,9 @@ test("toggleByKey dispatches stop() for a running daemon", async () => {
|
|
|
107
111
|
|
|
108
112
|
active = render(
|
|
109
113
|
<ViewProvider>
|
|
110
|
-
<
|
|
111
|
-
<
|
|
114
|
+
<AgentsProvider>
|
|
115
|
+
<DaemonsProvider descriptors={descriptors}>
|
|
116
|
+
<QuitProvider>
|
|
112
117
|
<Seed
|
|
113
118
|
rows={[
|
|
114
119
|
{ descriptor: descriptors[0], status: "running", lines: [], exitCode: null },
|
|
@@ -118,8 +123,9 @@ test("toggleByKey dispatches stop() for a running daemon", async () => {
|
|
|
118
123
|
/>
|
|
119
124
|
<Capture />
|
|
120
125
|
<ServerList />
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
</QuitProvider>
|
|
127
|
+
</DaemonsProvider>
|
|
128
|
+
</AgentsProvider>
|
|
123
129
|
</ViewProvider>,
|
|
124
130
|
)
|
|
125
131
|
await tick()
|
|
@@ -145,8 +151,9 @@ test("toggleByKey dispatches start() for an idle daemon", async () => {
|
|
|
145
151
|
|
|
146
152
|
active = render(
|
|
147
153
|
<ViewProvider>
|
|
148
|
-
<
|
|
149
|
-
<
|
|
154
|
+
<AgentsProvider>
|
|
155
|
+
<DaemonsProvider descriptors={descriptors}>
|
|
156
|
+
<QuitProvider>
|
|
150
157
|
<Seed
|
|
151
158
|
rows={[
|
|
152
159
|
{ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null },
|
|
@@ -155,8 +162,9 @@ test("toggleByKey dispatches start() for an idle daemon", async () => {
|
|
|
155
162
|
/>
|
|
156
163
|
<Capture />
|
|
157
164
|
<ServerList />
|
|
158
|
-
|
|
159
|
-
|
|
165
|
+
</QuitProvider>
|
|
166
|
+
</DaemonsProvider>
|
|
167
|
+
</AgentsProvider>
|
|
160
168
|
</ViewProvider>,
|
|
161
169
|
)
|
|
162
170
|
await tick()
|
package/src/views/ServerList.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// hotkeys (`↑/↓`, `enter`, `s`). Global hotkeys (`1..9`, `esc`, `q`) live
|
|
3
3
|
// in <GlobalHotkeys>.
|
|
4
4
|
|
|
5
|
-
import React from "react"
|
|
5
|
+
import React, { useState } from "react"
|
|
6
6
|
import { Box, Text, useInput } from "ink"
|
|
7
7
|
|
|
8
8
|
import type { DaemonStatus } from "../hooks/useDaemonProcess.ts"
|
|
@@ -10,6 +10,11 @@ import { useView } from "../state/view-context.tsx"
|
|
|
10
10
|
import { useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
|
|
11
11
|
import { useHotkeys } from "../hooks/useHotkeys.ts"
|
|
12
12
|
import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
|
|
13
|
+
import { ControlsHint } from "../components/ControlsHint.tsx"
|
|
14
|
+
|
|
15
|
+
const DAEMONS_CONTROLS =
|
|
16
|
+
"↑/↓ focus enter drill in s start/stop k takeover 1-9 jump g agents q quit " +
|
|
17
|
+
"● running ✗ crashed ⚠ blocked"
|
|
13
18
|
|
|
14
19
|
const STATUS_GLYPH: Record<DaemonStatus, string> = {
|
|
15
20
|
idle: "○",
|
|
@@ -36,6 +41,7 @@ const STATUS_COLOR: Record<DaemonStatus, string> = {
|
|
|
36
41
|
export function ServerList(): React.ReactElement {
|
|
37
42
|
const { rows } = useDaemons()
|
|
38
43
|
const { focusedIdx, setFocusedIdx, showLog } = useView()
|
|
44
|
+
const [controlsOpen, setControlsOpen] = useState(false)
|
|
39
45
|
// Hotkeys hook gives us the toggle; quit isn't called here so we pass
|
|
40
46
|
// a noop — the global useInput owns the q routing.
|
|
41
47
|
const { onToggleFocused } = useHotkeys(() => {})
|
|
@@ -43,6 +49,10 @@ export function ServerList(): React.ReactElement {
|
|
|
43
49
|
const rawModeSupported = process.stdin.isTTY === true
|
|
44
50
|
useInput(
|
|
45
51
|
(input, key) => {
|
|
52
|
+
if (input === "c") {
|
|
53
|
+
setControlsOpen((v) => !v)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
46
56
|
if (rows.length === 0) return
|
|
47
57
|
if (key.upArrow) {
|
|
48
58
|
setFocusedIdx((focusedIdx - 1 + rows.length) % rows.length)
|
|
@@ -66,11 +76,7 @@ export function ServerList(): React.ReactElement {
|
|
|
66
76
|
{rows.map((row, i) => (
|
|
67
77
|
<DaemonListRow key={row.descriptor.key} row={row} focused={i === focusedIdx} />
|
|
68
78
|
))}
|
|
69
|
-
<
|
|
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>
|
|
79
|
+
<ControlsHint expanded={controlsOpen} controls={DAEMONS_CONTROLS} />
|
|
74
80
|
</Box>
|
|
75
81
|
)
|
|
76
82
|
}
|