@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davstack/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Long-running terminal UI that spawns, owns, and surfaces the davstack daemons (vitest-server, playwright-server, logs-server).",
|
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
"commander": "^12.0.0",
|
|
19
19
|
"ink": "^5.0.1",
|
|
20
20
|
"react": "^18.3.1",
|
|
21
|
-
"tsx": "^4.19.0"
|
|
21
|
+
"tsx": "^4.19.0",
|
|
22
|
+
"@davstack/open-agents": "1.2.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
25
|
+
"@types/node": "^25.9.1",
|
|
24
26
|
"@types/react": "^18.3.3",
|
|
25
|
-
"ink-testing-library": "^4.0.0"
|
|
27
|
+
"ink-testing-library": "^4.0.0",
|
|
28
|
+
"typescript": "^5.0.0"
|
|
26
29
|
},
|
|
27
30
|
"publishConfig": {
|
|
28
31
|
"access": "public"
|
package/src/App.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { daemonRegistry, type DaemonDescriptor } from "./lib/daemon-registry.ts"
|
|
|
8
8
|
import { installGlobalTeardown } from "./lib/global-teardown.ts"
|
|
9
9
|
|
|
10
10
|
import { ViewProvider } from "./state/view-context.tsx"
|
|
11
|
+
import { AgentsProvider } from "./state/agents-context.tsx"
|
|
11
12
|
import { DaemonsProvider } from "./state/daemons-context.tsx"
|
|
12
13
|
import { QuitProvider, useQuit } from "./state/quit-context.tsx"
|
|
13
14
|
|
|
@@ -44,7 +45,8 @@ export function App({
|
|
|
44
45
|
|
|
45
46
|
return (
|
|
46
47
|
<ViewProvider>
|
|
47
|
-
<
|
|
48
|
+
<AgentsProvider>
|
|
49
|
+
<DaemonsProvider descriptors={filtered}>
|
|
48
50
|
<QuitProvider>
|
|
49
51
|
<DescriptorSync descriptors={filtered} />
|
|
50
52
|
{filtered.map((d) => (
|
|
@@ -61,7 +63,8 @@ export function App({
|
|
|
61
63
|
)}
|
|
62
64
|
</QuitController>
|
|
63
65
|
</QuitProvider>
|
|
64
|
-
|
|
66
|
+
</DaemonsProvider>
|
|
67
|
+
</AgentsProvider>
|
|
65
68
|
</ViewProvider>
|
|
66
69
|
)
|
|
67
70
|
}
|
package/src/cli.ts
CHANGED
|
@@ -13,11 +13,31 @@ import { runCheck, formatResult, exitCodeFor } from "./commands/check.ts"
|
|
|
13
13
|
|
|
14
14
|
function runStart(opts: { noColor?: boolean }): void {
|
|
15
15
|
if (opts.noColor) process.env.DAVSTACK_NO_COLOR = "1"
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// Use the terminal's alternate screen buffer so view transitions
|
|
17
|
+
// (drill in/back) don't leak prior frames into scrollback when Ink
|
|
18
|
+
// shrinks its render region. Falls back to a one-shot scrollback
|
|
19
|
+
// wipe when stdout isn't a TTY or DAVSTACK_NO_ALTSCREEN is set.
|
|
20
|
+
const altScreen =
|
|
21
|
+
process.stdout.isTTY === true && process.env.DAVSTACK_NO_ALTSCREEN !== "1"
|
|
22
|
+
if (altScreen) {
|
|
23
|
+
process.stdout.write("\x1b[?1049h\x1b[H")
|
|
24
|
+
} else {
|
|
25
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
|
|
26
|
+
}
|
|
27
|
+
const restore = () => {
|
|
28
|
+
if (altScreen) process.stdout.write("\x1b[?1049l")
|
|
29
|
+
}
|
|
30
|
+
process.once("exit", restore)
|
|
31
|
+
process.once("SIGINT", () => {
|
|
32
|
+
restore()
|
|
33
|
+
process.exit(130)
|
|
34
|
+
})
|
|
35
|
+
process.once("SIGTERM", () => {
|
|
36
|
+
restore()
|
|
37
|
+
process.exit(143)
|
|
38
|
+
})
|
|
39
|
+
const ink = render(React.createElement(App))
|
|
40
|
+
ink.waitUntilExit().finally(restore)
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
const program = new Command()
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Persistent bottom bar —
|
|
2
|
-
//
|
|
3
|
-
// focused (highlighted bold + inverse).
|
|
1
|
+
// Persistent bottom bar — daemon pills only. Running-agent pills used
|
|
2
|
+
// to live here too but the dedicated `g` view supersedes them.
|
|
4
3
|
|
|
5
4
|
import React from "react"
|
|
6
5
|
|
|
@@ -18,11 +17,13 @@ function statusToPill(s: DaemonRow["status"]): PillStatus {
|
|
|
18
17
|
export function BottomBar(): React.ReactElement {
|
|
19
18
|
const { rows } = useDaemons()
|
|
20
19
|
const { view } = useView()
|
|
20
|
+
|
|
21
21
|
const pills: DaemonPill[] = rows.map((r, i) => ({
|
|
22
22
|
key: String(i + 1),
|
|
23
23
|
daemonKey: r.descriptor.key,
|
|
24
24
|
label: r.descriptor.label,
|
|
25
25
|
status: statusToPill(r.status),
|
|
26
26
|
}))
|
|
27
|
+
|
|
27
28
|
return <StatusBar daemons={pills} focusedKey={view.kind === "log" ? view.key : undefined} />
|
|
28
29
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Collapsed-by-default keyboard hint row. Default shows `c for controls`;
|
|
2
|
+
// toggled state inlines the full hint string. Toggle is owned by the
|
|
3
|
+
// parent view via useInput so each view decides what `c` means.
|
|
4
|
+
|
|
5
|
+
import React from "react"
|
|
6
|
+
import { Box, Text } from "ink"
|
|
7
|
+
|
|
8
|
+
export interface ControlsHintProps {
|
|
9
|
+
expanded: boolean
|
|
10
|
+
controls: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ControlsHint({ expanded, controls }: ControlsHintProps): React.ReactElement {
|
|
14
|
+
return (
|
|
15
|
+
<Box marginTop={1}>
|
|
16
|
+
<Text dimColor>{expanded ? controls : "c for controls"}</Text>
|
|
17
|
+
</Box>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -6,6 +6,8 @@ import { Box, Text, useStdout } from "ink"
|
|
|
6
6
|
|
|
7
7
|
import { ServerList } from "../views/ServerList.tsx"
|
|
8
8
|
import { ServerLogView } from "../views/ServerLogView.tsx"
|
|
9
|
+
import { AgentsList } from "../views/AgentsList.tsx"
|
|
10
|
+
import { AgentTimelineView } from "../views/AgentTimelineView.tsx"
|
|
9
11
|
import { useView } from "../state/view-context.tsx"
|
|
10
12
|
import { useDaemons } from "../state/daemons-context.tsx"
|
|
11
13
|
import { getPackageVersion, getRepoRootSafe } from "../lib/package-info.ts"
|
|
@@ -20,6 +22,12 @@ export function MainView({ discoveryDone, hasAnyDaemon }: MainViewProps): React.
|
|
|
20
22
|
const { view } = useView()
|
|
21
23
|
const { rows } = useDaemons()
|
|
22
24
|
|
|
25
|
+
if (view.kind === "agent") {
|
|
26
|
+
return <AgentTimelineView jobId={view.id} />
|
|
27
|
+
}
|
|
28
|
+
if (view.kind === "agents") {
|
|
29
|
+
return <AgentsList />
|
|
30
|
+
}
|
|
23
31
|
if (!discoveryDone) {
|
|
24
32
|
return <Text dimColor>scanning .davstack/config…</Text>
|
|
25
33
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Tiny markdown renderer for Ink. Handles the subset our agent spec
|
|
2
|
+
// files actually use: headings, bullets, fenced code blocks, inline
|
|
3
|
+
// **bold** and `code`. Anything fancier (tables, links, blockquotes)
|
|
4
|
+
// falls through as plain text rather than blowing up the layout.
|
|
5
|
+
|
|
6
|
+
import React from "react"
|
|
7
|
+
import { Text } from "ink"
|
|
8
|
+
|
|
9
|
+
export interface MarkdownProps {
|
|
10
|
+
source: string
|
|
11
|
+
maxLines?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface InlineToken {
|
|
15
|
+
kind: "text" | "bold" | "code"
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Markdown({ source, maxLines }: MarkdownProps): React.ReactElement {
|
|
20
|
+
const rawLines = React.useMemo(
|
|
21
|
+
() => source.replace(/\r\n/g, "\n").split("\n"),
|
|
22
|
+
[source],
|
|
23
|
+
)
|
|
24
|
+
const sliced = maxLines != null ? rawLines.slice(0, maxLines) : rawLines
|
|
25
|
+
let inCodeFence = false
|
|
26
|
+
const elements: React.ReactNode[] = []
|
|
27
|
+
for (let i = 0; i < sliced.length; i += 1) {
|
|
28
|
+
const line = sliced[i]!
|
|
29
|
+
if (/^\s*```/.test(line)) {
|
|
30
|
+
inCodeFence = !inCodeFence
|
|
31
|
+
elements.push(
|
|
32
|
+
<Text key={i} dimColor>
|
|
33
|
+
{line}
|
|
34
|
+
</Text>,
|
|
35
|
+
)
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (inCodeFence) {
|
|
39
|
+
elements.push(
|
|
40
|
+
<Text key={i} color="yellow" dimColor>
|
|
41
|
+
{line}
|
|
42
|
+
</Text>,
|
|
43
|
+
)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
elements.push(renderLine(i, line))
|
|
47
|
+
}
|
|
48
|
+
return <>{elements}</>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderLine(key: number, line: string): React.ReactNode {
|
|
52
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
53
|
+
if (heading) {
|
|
54
|
+
const level = heading[1]!.length
|
|
55
|
+
const color = level === 1 ? "cyan" : level === 2 ? "yellow" : undefined
|
|
56
|
+
return (
|
|
57
|
+
<Text key={key} bold color={color}>
|
|
58
|
+
{heading[2]!}
|
|
59
|
+
</Text>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
if (/^\s*---+\s*$/.test(line)) {
|
|
63
|
+
return (
|
|
64
|
+
<Text key={key} dimColor>
|
|
65
|
+
{"─".repeat(60)}
|
|
66
|
+
</Text>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
const bullet = /^(\s*)([-*])\s+(.*)$/.exec(line)
|
|
70
|
+
if (bullet) {
|
|
71
|
+
return (
|
|
72
|
+
<Text key={key}>
|
|
73
|
+
{bullet[1]}
|
|
74
|
+
<Text color="cyan">• </Text>
|
|
75
|
+
{renderInline(bullet[3]!)}
|
|
76
|
+
</Text>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (line.trim().length === 0) {
|
|
80
|
+
return <Text key={key}> </Text>
|
|
81
|
+
}
|
|
82
|
+
return <Text key={key}>{renderInline(line)}</Text>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderInline(text: string): React.ReactNode {
|
|
86
|
+
const tokens = tokenizeInline(text)
|
|
87
|
+
return tokens.map((t, i) => {
|
|
88
|
+
if (t.kind === "bold")
|
|
89
|
+
return (
|
|
90
|
+
<Text key={i} bold>
|
|
91
|
+
{t.value}
|
|
92
|
+
</Text>
|
|
93
|
+
)
|
|
94
|
+
if (t.kind === "code")
|
|
95
|
+
return (
|
|
96
|
+
<Text key={i} color="yellow">
|
|
97
|
+
{t.value}
|
|
98
|
+
</Text>
|
|
99
|
+
)
|
|
100
|
+
return <Text key={i}>{t.value}</Text>
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tokenizeInline(text: string): InlineToken[] {
|
|
105
|
+
const out: InlineToken[] = []
|
|
106
|
+
let i = 0
|
|
107
|
+
while (i < text.length) {
|
|
108
|
+
if (text.startsWith("**", i)) {
|
|
109
|
+
const end = text.indexOf("**", i + 2)
|
|
110
|
+
if (end > i + 2) {
|
|
111
|
+
out.push({ kind: "bold", value: text.slice(i + 2, end) })
|
|
112
|
+
i = end + 2
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (text[i] === "`") {
|
|
117
|
+
const end = text.indexOf("`", i + 1)
|
|
118
|
+
if (end > i + 1) {
|
|
119
|
+
out.push({ kind: "code", value: text.slice(i + 1, end) })
|
|
120
|
+
i = end + 1
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let j = i + 1
|
|
125
|
+
while (j < text.length && !text.startsWith("**", j) && text[j] !== "`") j += 1
|
|
126
|
+
out.push({ kind: "text", value: text.slice(i, j) })
|
|
127
|
+
i = j
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
@@ -18,6 +18,11 @@ export interface DaemonPill {
|
|
|
18
18
|
status: DaemonStatus
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface AgentPill {
|
|
22
|
+
jobId: string
|
|
23
|
+
label: string
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
const STATUS_GLYPH: Record<DaemonStatus, string> = {
|
|
22
27
|
"not-running": "○",
|
|
23
28
|
running: "●",
|
|
@@ -34,9 +39,11 @@ const STATUS_COLOR: Record<DaemonStatus, string | undefined> = {
|
|
|
34
39
|
|
|
35
40
|
interface StatusBarProps {
|
|
36
41
|
daemons: DaemonPill[]
|
|
42
|
+
agents?: AgentPill[]
|
|
37
43
|
// Daemon key currently being viewed (log view). The matching pill is
|
|
38
44
|
// rendered bold + inverse so users can see which one they're in.
|
|
39
45
|
focusedKey?: string
|
|
46
|
+
focusedAgentId?: string
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
// Exported for unit tests — avoids depending on ANSI escape detection
|
|
@@ -45,14 +52,24 @@ export function isPillFocused(pill: DaemonPill, focusedKey: string | undefined):
|
|
|
45
52
|
return focusedKey !== undefined && pill.daemonKey === focusedKey
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
export function
|
|
55
|
+
export function isAgentPillFocused(pill: AgentPill, focusedAgentId: string | undefined): boolean {
|
|
56
|
+
return focusedAgentId !== undefined && pill.jobId === focusedAgentId
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function StatusBar({
|
|
60
|
+
daemons,
|
|
61
|
+
agents = [],
|
|
62
|
+
focusedKey,
|
|
63
|
+
focusedAgentId,
|
|
64
|
+
}: StatusBarProps): React.ReactElement {
|
|
49
65
|
const noColor = useNoColor()
|
|
50
66
|
return (
|
|
51
67
|
<Box flexDirection="row">
|
|
52
68
|
{daemons.map((d, i) => {
|
|
53
69
|
const focused = isPillFocused(d, focusedKey)
|
|
70
|
+
const marginRight = i === daemons.length - 1 && agents.length === 0 ? 0 : 2
|
|
54
71
|
return (
|
|
55
|
-
<Box key={d.key} marginRight={
|
|
72
|
+
<Box key={d.key} marginRight={marginRight}>
|
|
56
73
|
<Text inverse={focused} bold={focused}>
|
|
57
74
|
{d.key} {d.label}{" "}
|
|
58
75
|
<Text color={colorOrUndef(STATUS_COLOR[d.status], noColor)}>
|
|
@@ -62,6 +79,21 @@ export function StatusBar({ daemons, focusedKey }: StatusBarProps): React.ReactE
|
|
|
62
79
|
</Box>
|
|
63
80
|
)
|
|
64
81
|
})}
|
|
82
|
+
{agents.length > 0 ? (
|
|
83
|
+
<Box marginRight={2}>
|
|
84
|
+
<Text dimColor>|</Text>
|
|
85
|
+
</Box>
|
|
86
|
+
) : null}
|
|
87
|
+
{agents.map((a, i) => {
|
|
88
|
+
const focused = isAgentPillFocused(a, focusedAgentId)
|
|
89
|
+
return (
|
|
90
|
+
<Box key={a.jobId} marginRight={i === agents.length - 1 ? 0 : 2}>
|
|
91
|
+
<Text inverse={focused} bold={focused}>
|
|
92
|
+
<Text color={colorOrUndef("yellow", noColor)}>{a.label}</Text>
|
|
93
|
+
</Text>
|
|
94
|
+
</Box>
|
|
95
|
+
)
|
|
96
|
+
})}
|
|
65
97
|
</Box>
|
|
66
98
|
)
|
|
67
99
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JobRecord } from "@davstack/open-agents/core/jobs"
|
|
2
|
+
import type { AgentAdapter } from "@davstack/open-agents/adapters/types"
|
|
3
|
+
import { cursorAdapter } from "@davstack/open-agents/adapters/cursor"
|
|
4
|
+
import { geminiAdapter } from "@davstack/open-agents/adapters/gemini"
|
|
5
|
+
|
|
6
|
+
export function adapterForJob(job: Pick<JobRecord, "model">): AgentAdapter {
|
|
7
|
+
const m = job.model.toLowerCase()
|
|
8
|
+
if (m.startsWith("gemini-")) return geminiAdapter
|
|
9
|
+
return cursorAdapter
|
|
10
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Hook test for useAgentJobs: fixture jobs dir under tmp OPEN_AGENTS_HOME,
|
|
2
|
+
// ink-testing-library probe, mtime poll refresh.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import React from "react"
|
|
8
|
+
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
|
9
|
+
import { render } from "ink-testing-library"
|
|
10
|
+
import { Text } from "ink"
|
|
11
|
+
|
|
12
|
+
import type { JobRecord } from "@davstack/open-agents/core/jobs"
|
|
13
|
+
import { jobsDir } from "@davstack/open-agents/core/paths"
|
|
14
|
+
|
|
15
|
+
import { useAgentJobs, type UseAgentJobsResult } from "./useAgentJobs.ts"
|
|
16
|
+
|
|
17
|
+
const origHome = process.env.OPEN_AGENTS_HOME
|
|
18
|
+
let tmpRoot = ""
|
|
19
|
+
let repoPath = ""
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
vi.useFakeTimers()
|
|
23
|
+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "davstack-tui-jobs-"))
|
|
24
|
+
process.env.OPEN_AGENTS_HOME = tmpRoot
|
|
25
|
+
repoPath = path.join(tmpRoot, "repo")
|
|
26
|
+
await fs.mkdir(repoPath, { recursive: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
vi.useRealTimers()
|
|
31
|
+
if (origHome === undefined) delete process.env.OPEN_AGENTS_HOME
|
|
32
|
+
else process.env.OPEN_AGENTS_HOME = origHome
|
|
33
|
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
async function writeJob(opts: {
|
|
37
|
+
id: string
|
|
38
|
+
status?: JobRecord["status"]
|
|
39
|
+
startedAt?: string
|
|
40
|
+
model?: string
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
const dir = jobsDir(repoPath)
|
|
43
|
+
await fs.mkdir(dir, { recursive: true })
|
|
44
|
+
const record: JobRecord = {
|
|
45
|
+
id: opts.id,
|
|
46
|
+
repoPath,
|
|
47
|
+
prompt: "fixture prompt",
|
|
48
|
+
model: opts.model ?? "composer-2.5",
|
|
49
|
+
status: opts.status ?? "done",
|
|
50
|
+
startedAt: opts.startedAt ?? new Date().toISOString(),
|
|
51
|
+
rawLogPath: path.join(dir, "logs", `${opts.id}.ndjson`),
|
|
52
|
+
}
|
|
53
|
+
await fs.writeFile(path.join(dir, `${opts.id}.json`), JSON.stringify(record, null, 2))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Probe({
|
|
57
|
+
onRender,
|
|
58
|
+
}: {
|
|
59
|
+
onRender: (r: UseAgentJobsResult) => void
|
|
60
|
+
}): React.ReactElement {
|
|
61
|
+
const r = useAgentJobs(repoPath)
|
|
62
|
+
onRender(r)
|
|
63
|
+
return React.createElement(Text, null, `n=${r.jobs.length}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let active: ReturnType<typeof render> | null = null
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
active?.unmount()
|
|
69
|
+
active = null
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
async function flush(): Promise<void> {
|
|
73
|
+
await Promise.resolve()
|
|
74
|
+
await vi.runOnlyPendingTimersAsync()
|
|
75
|
+
await Promise.resolve()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("lists fixture jobs from tmp OPEN_AGENTS_HOME jobs dir", async () => {
|
|
79
|
+
await writeJob({ id: "job-a", status: "done", startedAt: "2026-05-26T10:00:00.000Z" })
|
|
80
|
+
await writeJob({ id: "job-b", status: "running", startedAt: "2026-05-26T11:00:00.000Z" })
|
|
81
|
+
|
|
82
|
+
let captured: UseAgentJobsResult | null = null
|
|
83
|
+
active = render(
|
|
84
|
+
React.createElement(Probe, {
|
|
85
|
+
onRender: (r) => {
|
|
86
|
+
captured = r
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
await flush()
|
|
91
|
+
|
|
92
|
+
expect(captured!.jobs.map((j) => j.id)).toEqual(["job-b", "job-a"])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("returns empty list when jobs dir does not exist yet", async () => {
|
|
96
|
+
let captured: UseAgentJobsResult | null = null
|
|
97
|
+
active = render(
|
|
98
|
+
React.createElement(Probe, {
|
|
99
|
+
onRender: (r) => {
|
|
100
|
+
captured = r
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
await flush()
|
|
105
|
+
expect(captured!.jobs).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("mtime poll picks up a newly written job", async () => {
|
|
109
|
+
await writeJob({ id: "job-1" })
|
|
110
|
+
let captured: UseAgentJobsResult | null = null
|
|
111
|
+
active = render(
|
|
112
|
+
React.createElement(Probe, {
|
|
113
|
+
onRender: (r) => {
|
|
114
|
+
captured = r
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
await flush()
|
|
119
|
+
expect(captured!.jobs).toHaveLength(1)
|
|
120
|
+
|
|
121
|
+
await writeJob({ id: "job-2", status: "running" })
|
|
122
|
+
const dir = jobsDir(repoPath)
|
|
123
|
+
const now = new Date()
|
|
124
|
+
await fs.utimes(dir, now, now)
|
|
125
|
+
|
|
126
|
+
await vi.advanceTimersByTimeAsync(500)
|
|
127
|
+
await flush()
|
|
128
|
+
|
|
129
|
+
expect(captured!.jobs.map((j) => j.id).sort()).toEqual(["job-1", "job-2"])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("ignores non-job json files in the jobs dir", async () => {
|
|
133
|
+
const dir = jobsDir(repoPath)
|
|
134
|
+
await fs.mkdir(dir, { recursive: true })
|
|
135
|
+
await fs.writeFile(path.join(dir, "README.json"), '{"nope":true}', "utf8")
|
|
136
|
+
await writeJob({ id: "job-only" })
|
|
137
|
+
|
|
138
|
+
let captured: UseAgentJobsResult | null = null
|
|
139
|
+
active = render(
|
|
140
|
+
React.createElement(Probe, {
|
|
141
|
+
onRender: (r) => {
|
|
142
|
+
captured = r
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
await flush()
|
|
147
|
+
|
|
148
|
+
expect(captured!.jobs.map((j) => j.id)).toEqual(["job-only"])
|
|
149
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import { listJobs, type JobRecord } from "@davstack/open-agents/core/jobs"
|
|
5
|
+
import { jobsDir } from "@davstack/open-agents/core/paths"
|
|
6
|
+
|
|
7
|
+
export interface UseAgentJobsResult {
|
|
8
|
+
jobs: JobRecord[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function jobStableKey(j: JobRecord): string {
|
|
12
|
+
return `${j.id}:${j.status}:${j.finishedAt ?? ""}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useAgentJobs(repoPath: string): UseAgentJobsResult {
|
|
16
|
+
const [jobs, setJobs] = useState<JobRecord[]>([])
|
|
17
|
+
const [tick, setTick] = useState(0)
|
|
18
|
+
const lastMtimeRef = useRef(0)
|
|
19
|
+
const lastKeysRef = useRef("")
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const id = setInterval(() => {
|
|
23
|
+
try {
|
|
24
|
+
const st = fs.statSync(jobsDir(repoPath))
|
|
25
|
+
const m = st.mtimeMs
|
|
26
|
+
if (m === lastMtimeRef.current) return
|
|
27
|
+
lastMtimeRef.current = m
|
|
28
|
+
setTick((t) => t + 1)
|
|
29
|
+
} catch {
|
|
30
|
+
/* dir not yet created */
|
|
31
|
+
}
|
|
32
|
+
}, 500)
|
|
33
|
+
return () => clearInterval(id)
|
|
34
|
+
}, [repoPath])
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const next = listJobs(repoPath, { limit: 20 })
|
|
38
|
+
const keys = next.map(jobStableKey).join("|")
|
|
39
|
+
if (keys === lastKeysRef.current) return
|
|
40
|
+
lastKeysRef.current = keys
|
|
41
|
+
setJobs(next)
|
|
42
|
+
}, [repoPath, tick])
|
|
43
|
+
|
|
44
|
+
const sorted = useMemo(() => sortAgentJobs(jobs), [jobs])
|
|
45
|
+
|
|
46
|
+
return { jobs: sorted }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sortAgentJobs(jobs: JobRecord[]): JobRecord[] {
|
|
50
|
+
const running = jobs.filter((j) => j.status === "running")
|
|
51
|
+
const rest = jobs.filter((j) => j.status !== "running")
|
|
52
|
+
return [...running, ...rest]
|
|
53
|
+
}
|