agent-ui-kit 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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/package.json +40 -0
- package/src/client.js +261 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 arsen-ask-lx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# agent-ui-kit
|
|
2
|
+
|
|
3
|
+
**Point at an element in your running app, say what is wrong — the note lands
|
|
4
|
+
in a file your coding agent reads.**
|
|
5
|
+
|
|
6
|
+
Between "this button here" and `Rail.tsx:69` somebody has to build a bridge.
|
|
7
|
+
Without one, every conversation about the UI is made of prose descriptions,
|
|
8
|
+
and prose is read two ways.
|
|
9
|
+
|
|
10
|
+
It is a Vite plugin: it lives in the dev server and there is
|
|
11
|
+
nothing to import in your source.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -D agent-ui-kit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
// vite.config.js
|
|
19
|
+
import { defineConfig } from "vite";
|
|
20
|
+
import react from "@vitejs/plugin-react";
|
|
21
|
+
import { agentUiKit } from "agent-ui-kit";
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
plugins: [react(), agentUiKit()],
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That is the whole installation. Hold **Alt** — the element under the cursor
|
|
29
|
+
lights up. Click — a field opens. Type, press **Enter**.
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
## the channel list slides under the profile instead of scrolling
|
|
33
|
+
|
|
34
|
+
- **where:** `ChatScreen › Rail › RoomList › SidebarSection`
|
|
35
|
+
- **what:** `<button>` — «Channels»
|
|
36
|
+
- **classes:** `flex min-w-0 flex-1 items-center gap-1 rounded px-2.5 py-1 …`
|
|
37
|
+
- **page:** `/c/01a0814d-3bd0-708e-bed0-b1be60d2bbee`
|
|
38
|
+
- **when:** 2026-09-08 18:20:30
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then you say to your agent: *"work through NOTES.md"*. The component chain
|
|
42
|
+
plus the class list is enough to find the source line with a single search.
|
|
43
|
+
|
|
44
|
+
## Why it is a file and not an MCP server
|
|
45
|
+
|
|
46
|
+
An MCP server would mean either the agent polling for notes or another daemon
|
|
47
|
+
to keep running. A file needs neither: the agent reads it when it looks, and
|
|
48
|
+
**any** agent can — Claude Code, Cursor, Codex, Copilot, or a human with
|
|
49
|
+
`git diff`. Nothing here is tied to one vendor.
|
|
50
|
+
|
|
51
|
+
You walk the screen and leave ten notes in a row; nobody should wait while
|
|
52
|
+
each one is processed. The file collects, the agent works through the list
|
|
53
|
+
afterwards.
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
agentUiKit({
|
|
59
|
+
file: "NOTES.md", // where notes are collected
|
|
60
|
+
key: "alt", // "alt" | "ctrl" | "meta"
|
|
61
|
+
color: "#e5484d", // highlight colour
|
|
62
|
+
placeholder: "what is wrong? Enter — save, Esc — cancel",
|
|
63
|
+
route: "/__agent-ui-kit", // change only on a collision
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## What it reads off the element
|
|
68
|
+
|
|
69
|
+
- **the component chain** — pulled from React's own fibers on the DOM node
|
|
70
|
+
(`ChatScreen › Rail › RoomList`). React only, and only in dev;
|
|
71
|
+
- **tag, classes, `id`, `data-testid`, a little text** — for everything else
|
|
72
|
+
and for every other framework.
|
|
73
|
+
|
|
74
|
+
If no component names are found the note is still recorded, with the tag, the
|
|
75
|
+
classes and the text. Tooling that dies together with somebody else's
|
|
76
|
+
undocumented detail is worse than no tooling at all.
|
|
77
|
+
|
|
78
|
+
## Dev only
|
|
79
|
+
|
|
80
|
+
The plugin declares `apply: "serve"`. The client is injected by the dev
|
|
81
|
+
server as a virtual module, so there is nothing to import in your source and
|
|
82
|
+
nothing to remember to strip from a production build.
|
|
83
|
+
|
|
84
|
+
## Three things learned the hard way
|
|
85
|
+
|
|
86
|
+
They are in the source as comments, and they are why this is a package rather
|
|
87
|
+
than a snippet:
|
|
88
|
+
|
|
89
|
+
- **no `prompt()` / `confirm()`** — a native dialog freezes the page and
|
|
90
|
+
breaks any automation driving the browser from outside;
|
|
91
|
+
- **the modifier-click belongs to the plugin entirely** — otherwise a note
|
|
92
|
+
about a button also presses that button;
|
|
93
|
+
- **the field closes on click-outside, not on blur** — `blur` fires before
|
|
94
|
+
Enter gets a chance to run, and ate what was typed.
|
|
95
|
+
|
|
96
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-ui-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Point at an element in your running app, say what is wrong — the note lands in a file your coding agent reads.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vite-plugin",
|
|
7
|
+
"ai",
|
|
8
|
+
"agent",
|
|
9
|
+
"feedback",
|
|
10
|
+
"annotate",
|
|
11
|
+
"claude",
|
|
12
|
+
"cursor",
|
|
13
|
+
"devtools"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./src/index.d.ts",
|
|
20
|
+
"default": "./src/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"vite": ">=5"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/arsen-ask-lx/agent-ui-kit.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/arsen-ask-lx/agent-ui-kit#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/arsen-ask-lx/agent-ui-kit/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser side of agent-ui-kit.
|
|
3
|
+
*
|
|
4
|
+
* Hold the modifier key — the element under the cursor lights up.
|
|
5
|
+
* Click — a small field opens. Type, press Enter — the note is appended
|
|
6
|
+
* to a file on disk, together with a description of what you pointed at.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS. Between "this button here" and `Rail.tsx:69` somebody
|
|
9
|
+
* has to build a bridge. Without one, every conversation about the UI is
|
|
10
|
+
* made of prose descriptions, and prose is read two ways.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ DEV SERVER ONLY. The plugin declares `apply: "serve"`, so nothing
|
|
13
|
+
* here reaches a production bundle.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ NO `prompt()` / `confirm()`. A native dialog freezes the whole page
|
|
16
|
+
* and breaks automation that drives the browser from outside — which is
|
|
17
|
+
* exactly the audience this tool serves.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/* global __AGENT_UI_KIT_OPTIONS__ */
|
|
21
|
+
const OPTIONS = __AGENT_UI_KIT_OPTIONS__;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whose node is this — the chain of framework components, read off the
|
|
25
|
+
* DOM node itself.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ READ FROM THE FRAMEWORK, NOT FROM THE BUILD. The first attempt wrote
|
|
28
|
+
* source positions into the markup with a Babel plugin, and broke against
|
|
29
|
+
* `@vitejs/plugin-react` 6, which is built on oxc — the Babel hook points
|
|
30
|
+
* are simply gone.
|
|
31
|
+
*
|
|
32
|
+
* React in dev mode hangs a `__reactFiber$…` property (random suffix) on
|
|
33
|
+
* every DOM node. Walking it upwards yields `ChatScreen › Rail › RoomList`
|
|
34
|
+
* which, together with the class list, is enough to find the source line
|
|
35
|
+
* with a single search.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ THESE ARE FRAMEWORK INTERNALS AND THEY MAY VANISH. That is why there
|
|
38
|
+
* is not one "just in case" guard here: if no names are found, the note is
|
|
39
|
+
* still recorded — with the tag, the classes and the text. Tooling that
|
|
40
|
+
* dies together with somebody else's undocumented detail is worse than no
|
|
41
|
+
* tooling at all.
|
|
42
|
+
*/
|
|
43
|
+
function fiberOf(node) {
|
|
44
|
+
const key = Object.keys(node).find((one) => one.startsWith("__reactFiber$"));
|
|
45
|
+
return key ? (node[key] ?? null) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nameOf(fiber) {
|
|
49
|
+
if (typeof fiber.type !== "function") return undefined;
|
|
50
|
+
return fiber.type.displayName ?? fiber.type.name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownersOf(node) {
|
|
54
|
+
const names = [];
|
|
55
|
+
let fiber = fiberOf(node);
|
|
56
|
+
// A depth limit on purpose: trees get deep and we only want the nearest.
|
|
57
|
+
for (let step = 0; fiber && step < 40 && names.length < 4; step++) {
|
|
58
|
+
const name = nameOf(fiber);
|
|
59
|
+
if (name && !names.includes(name)) names.unshift(name);
|
|
60
|
+
fiber = fiber.return ?? null;
|
|
61
|
+
}
|
|
62
|
+
return names.length > 0 ? names.join(" › ") : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How the element is recognised by eye: tag, a little text, classes. */
|
|
66
|
+
function describe(node) {
|
|
67
|
+
return {
|
|
68
|
+
tag: node.tagName.toLowerCase(),
|
|
69
|
+
classes: node.getAttribute("class") ?? "",
|
|
70
|
+
sample: (node.textContent ?? "").trim().slice(0, 60),
|
|
71
|
+
id: node.id || "",
|
|
72
|
+
testId: node.getAttribute("data-testid") ?? "",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ⚠️ COLOURS ARE SELF-CONTAINED, NOT TAKEN FROM THE HOST'S THEME.
|
|
78
|
+
* An earlier version used the host project's CSS custom properties. That
|
|
79
|
+
* is right inside one project and wrong in a package: in a project that
|
|
80
|
+
* has no such tokens the highlight is invisible, and an invisible
|
|
81
|
+
* highlight is indistinguishable from a broken plugin.
|
|
82
|
+
*/
|
|
83
|
+
function frameFor(node) {
|
|
84
|
+
const frame = document.createElement("div");
|
|
85
|
+
const at = node.getBoundingClientRect();
|
|
86
|
+
frame.style.cssText = [
|
|
87
|
+
"position:fixed",
|
|
88
|
+
`left:${at.left}px`,
|
|
89
|
+
`top:${at.top}px`,
|
|
90
|
+
`width:${at.width}px`,
|
|
91
|
+
`height:${at.height}px`,
|
|
92
|
+
`border:2px solid ${OPTIONS.color}`,
|
|
93
|
+
"border-radius:4px",
|
|
94
|
+
`background:${OPTIONS.color}1a`,
|
|
95
|
+
"pointer-events:none",
|
|
96
|
+
"z-index:2147483646",
|
|
97
|
+
].join(";");
|
|
98
|
+
return frame;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The note field, next to the element. Resolves with the text or null.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ ENTER IS HANDLED EXPLICITLY, NOT THROUGH IMPLICIT FORM SUBMISSION.
|
|
105
|
+
* Implicit submit depends on how many fields the form has and whether it
|
|
106
|
+
* has a button; an end-to-end run caught this — the field closed and the
|
|
107
|
+
* note went nowhere.
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ CLOSING IS ON CLICK-OUTSIDE, NOT ON BLUR. `blur` fires before Enter
|
|
110
|
+
* gets a chance to run and ate what was typed. Same defect, other half.
|
|
111
|
+
*/
|
|
112
|
+
function ask(node) {
|
|
113
|
+
return new Promise((done) => {
|
|
114
|
+
const at = node.getBoundingClientRect();
|
|
115
|
+
const box = document.createElement("div");
|
|
116
|
+
box.style.cssText = [
|
|
117
|
+
"position:fixed",
|
|
118
|
+
`left:${Math.max(8, Math.min(at.left, window.innerWidth - 348))}px`,
|
|
119
|
+
`top:${Math.min(at.bottom + 8, window.innerHeight - 90)}px`,
|
|
120
|
+
"z-index:2147483647",
|
|
121
|
+
"padding:8px",
|
|
122
|
+
"border-radius:12px",
|
|
123
|
+
"background:#1b1b1e",
|
|
124
|
+
"box-shadow:0 8px 24px rgb(0 0 0 / 40%)",
|
|
125
|
+
"font:14px system-ui,sans-serif",
|
|
126
|
+
].join(";");
|
|
127
|
+
|
|
128
|
+
const field = document.createElement("input");
|
|
129
|
+
field.placeholder = OPTIONS.placeholder;
|
|
130
|
+
field.style.cssText = [
|
|
131
|
+
"width:300px",
|
|
132
|
+
"padding:8px 10px",
|
|
133
|
+
"border:0",
|
|
134
|
+
"border-radius:8px",
|
|
135
|
+
"background:#0f0f11",
|
|
136
|
+
"color:#f1f1f3",
|
|
137
|
+
"font:inherit",
|
|
138
|
+
"outline:none",
|
|
139
|
+
].join(";");
|
|
140
|
+
|
|
141
|
+
box.append(field);
|
|
142
|
+
document.body.append(box);
|
|
143
|
+
field.focus();
|
|
144
|
+
|
|
145
|
+
let closed = false;
|
|
146
|
+
const close = (answer) => {
|
|
147
|
+
if (closed) return;
|
|
148
|
+
closed = true;
|
|
149
|
+
box.remove();
|
|
150
|
+
document.removeEventListener("mousedown", outside, true);
|
|
151
|
+
done(answer);
|
|
152
|
+
};
|
|
153
|
+
function outside(event) {
|
|
154
|
+
if (!box.contains(event.target)) close(null);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
field.addEventListener("keydown", (event) => {
|
|
158
|
+
// Swallow everything: the page underneath must neither hear the
|
|
159
|
+
// typing nor read Escape as "close the menu".
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
if (event.key === "Enter") {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
close(field.value.trim() || null);
|
|
164
|
+
}
|
|
165
|
+
if (event.key === "Escape") close(null);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
document.addEventListener("mousedown", outside, true);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ⚠️ ARMED EXACTLY ONCE PER PAGE LIFETIME. Hot reload re-executes the
|
|
174
|
+
* module, and without this mark the handlers stacked up: one click opened
|
|
175
|
+
* three fields on top of each other and the page eventually locked up.
|
|
176
|
+
*
|
|
177
|
+
* The mark lives on `window`, not in the module: after a reload the module
|
|
178
|
+
* is new and so are its variables. Only what lives outside survives.
|
|
179
|
+
*/
|
|
180
|
+
const ONCE = "__agentUiKitStarted";
|
|
181
|
+
|
|
182
|
+
function held(event) {
|
|
183
|
+
return OPTIONS.key === "alt"
|
|
184
|
+
? event.altKey
|
|
185
|
+
: OPTIONS.key === "ctrl"
|
|
186
|
+
? event.ctrlKey
|
|
187
|
+
: event.metaKey;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function start() {
|
|
191
|
+
if (window[ONCE]) return;
|
|
192
|
+
window[ONCE] = true;
|
|
193
|
+
|
|
194
|
+
let frame = null;
|
|
195
|
+
let asking = false;
|
|
196
|
+
|
|
197
|
+
const clear = () => {
|
|
198
|
+
frame?.remove();
|
|
199
|
+
frame = null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
document.addEventListener(
|
|
203
|
+
"mousemove",
|
|
204
|
+
(event) => {
|
|
205
|
+
if (asking) return;
|
|
206
|
+
if (!held(event)) return clear();
|
|
207
|
+
const node = document.elementFromPoint(event.clientX, event.clientY);
|
|
208
|
+
clear();
|
|
209
|
+
if (!node) return;
|
|
210
|
+
frame = frameFor(node);
|
|
211
|
+
document.body.append(frame);
|
|
212
|
+
},
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
document.addEventListener("keyup", () => {
|
|
217
|
+
if (!asking) clear();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
document.addEventListener(
|
|
221
|
+
"click",
|
|
222
|
+
(event) => {
|
|
223
|
+
if (!held(event) || asking) return;
|
|
224
|
+
const node = event.target;
|
|
225
|
+
if (!node) return;
|
|
226
|
+
|
|
227
|
+
// The modifier-click belongs to us entirely: the application must not
|
|
228
|
+
// hear it, or a note about a button would also press that button.
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
|
|
232
|
+
asking = true;
|
|
233
|
+
void (async () => {
|
|
234
|
+
const text = await ask(node);
|
|
235
|
+
asking = false;
|
|
236
|
+
clear();
|
|
237
|
+
if (!text) return;
|
|
238
|
+
|
|
239
|
+
const note = { text, where: ownersOf(node), url: location.pathname, ...describe(node) };
|
|
240
|
+
try {
|
|
241
|
+
const answer = await fetch(OPTIONS.route, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "content-type": "application/json" },
|
|
244
|
+
body: JSON.stringify(note),
|
|
245
|
+
});
|
|
246
|
+
// Say it out loud on success too: silence is indistinguishable
|
|
247
|
+
// from loss, and loss is precisely what we already missed once.
|
|
248
|
+
if (answer.ok) console.info(`[agent-ui-kit] noted: ${text} → ${note.where ?? note.tag}`);
|
|
249
|
+
else console.warn(`[agent-ui-kit] not saved: server answered ${answer.status}`);
|
|
250
|
+
} catch {
|
|
251
|
+
console.warn("[agent-ui-kit] not sent: the dev server did not answer");
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
},
|
|
255
|
+
true,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
console.info(`[agent-ui-kit] on — hold ${OPTIONS.key} to highlight, ${OPTIONS.key}+click to leave a note`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
start();
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
|
|
3
|
+
export interface AgentUiKitOptions {
|
|
4
|
+
/** Where notes are collected. Default: `NOTES.md` in the project root. */
|
|
5
|
+
file?: string;
|
|
6
|
+
/** Which key to hold while pointing. Default: `alt`. */
|
|
7
|
+
key?: "alt" | "ctrl" | "meta";
|
|
8
|
+
/** Highlight colour. Default: `#e5484d`. */
|
|
9
|
+
color?: string;
|
|
10
|
+
/** Placeholder in the note field. */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Where the browser posts notes. Change only on a collision. */
|
|
13
|
+
route?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function agentUiKit(options?: AgentUiKitOptions): Plugin;
|
|
17
|
+
export default agentUiKit;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* agent-ui-kit — point at an element, say what is wrong, the note lands
|
|
7
|
+
* in a file your coding agent reads.
|
|
8
|
+
*
|
|
9
|
+
* WHY A FILE AND NOT A CONSOLE LINE. You walk the screen and leave ten notes
|
|
10
|
+
* in a row; nobody should wait while each is processed. The file collects
|
|
11
|
+
* them, the agent works through the list afterwards.
|
|
12
|
+
*
|
|
13
|
+
* WHY NOT MCP. An MCP server would mean the agent polling, or another daemon
|
|
14
|
+
* to run. A file needs neither: the agent reads it when it looks, and any
|
|
15
|
+
* agent can — this is not tied to one vendor.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ DEV SERVER ONLY (`apply: "serve"`). Nothing here reaches a production
|
|
18
|
+
* build: the client is injected by the dev server and the route lives in its
|
|
19
|
+
* middleware.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CLIENT = "\0virtual:agent-ui-kit/client";
|
|
23
|
+
|
|
24
|
+
const DEFAULTS = {
|
|
25
|
+
/** Where notes are collected. Markdown: read by a human and by an agent. */
|
|
26
|
+
file: "NOTES.md",
|
|
27
|
+
/** Which key to hold. `alt` | `ctrl` | `meta`. */
|
|
28
|
+
key: "alt",
|
|
29
|
+
/** Highlight colour. Self-contained on purpose — see client.js. */
|
|
30
|
+
color: "#e5484d",
|
|
31
|
+
/** Placeholder in the note field. */
|
|
32
|
+
placeholder: "what is wrong? Enter — save, Esc — cancel",
|
|
33
|
+
/** Where the browser posts notes. Change only on a collision. */
|
|
34
|
+
route: "/__agent-ui-kit",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One note, one entry.
|
|
39
|
+
*
|
|
40
|
+
* Components come first and on their own line: they are what you open the
|
|
41
|
+
* file by, so they must be findable by eye rather than read out of a
|
|
42
|
+
* paragraph. Classes are the second key — they locate the exact line inside
|
|
43
|
+
* the file.
|
|
44
|
+
*/
|
|
45
|
+
function entry(note) {
|
|
46
|
+
const when = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
47
|
+
const where = note.where || "component not identified";
|
|
48
|
+
const what = `\`<${note.tag}>\`${note.sample ? ` — «${note.sample}»` : ""}`;
|
|
49
|
+
return [
|
|
50
|
+
``,
|
|
51
|
+
`## ${note.text}`,
|
|
52
|
+
``,
|
|
53
|
+
`- **where:** \`${where}\``,
|
|
54
|
+
`- **what:** ${what}`,
|
|
55
|
+
`- **classes:** \`${note.classes || "—"}\``,
|
|
56
|
+
note.id ? `- **id:** \`${note.id}\`` : null,
|
|
57
|
+
note.testId ? `- **data-testid:** \`${note.testId}\`` : null,
|
|
58
|
+
`- **page:** \`${note.url}\``,
|
|
59
|
+
`- **when:** ${when}`,
|
|
60
|
+
``,
|
|
61
|
+
]
|
|
62
|
+
.filter((line) => line !== null)
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readBody(request) {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
69
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {Partial<typeof DEFAULTS>} [options]
|
|
74
|
+
*/
|
|
75
|
+
export function agentUiKit(options = {}) {
|
|
76
|
+
const settings = { ...DEFAULTS, ...options };
|
|
77
|
+
const notes = resolve(process.cwd(), settings.file);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name: "agent-ui-kit",
|
|
81
|
+
apply: "serve",
|
|
82
|
+
|
|
83
|
+
// The client is a virtual module: nothing to copy into the host project,
|
|
84
|
+
// and nothing to keep in sync with it.
|
|
85
|
+
resolveId(id) {
|
|
86
|
+
return id === CLIENT.slice(1) ? CLIENT : null;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async load(id) {
|
|
90
|
+
if (id !== CLIENT) return null;
|
|
91
|
+
const source = await readFile(new URL("./client.js", import.meta.url), "utf8");
|
|
92
|
+
const runtime = {
|
|
93
|
+
key: settings.key,
|
|
94
|
+
color: settings.color,
|
|
95
|
+
placeholder: settings.placeholder,
|
|
96
|
+
route: settings.route,
|
|
97
|
+
};
|
|
98
|
+
return source.replace("__AGENT_UI_KIT_OPTIONS__", JSON.stringify(runtime));
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// ⚠️ INJECTED BY THE PLUGIN, NOT IMPORTED BY THE HOST. One line in the
|
|
102
|
+
// config is the whole installation; a project that has to import a client
|
|
103
|
+
// file also has to remember to strip it from production.
|
|
104
|
+
transformIndexHtml() {
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
tag: "script",
|
|
108
|
+
attrs: { type: "module", src: `/@id/${CLIENT.slice(1)}` },
|
|
109
|
+
injectTo: "body",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
configureServer(server) {
|
|
115
|
+
server.middlewares.use(settings.route, (request, response) => {
|
|
116
|
+
if (request.method !== "POST") {
|
|
117
|
+
response.statusCode = 405;
|
|
118
|
+
response.end();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
void (async () => {
|
|
122
|
+
try {
|
|
123
|
+
const note = await readBody(request);
|
|
124
|
+
await mkdir(dirname(notes), { recursive: true });
|
|
125
|
+
await appendFile(notes, entry(note), "utf8");
|
|
126
|
+
// Say it in the dev-server log too: the person sees the note did
|
|
127
|
+
// not fly off into nowhere without opening the file.
|
|
128
|
+
server.config.logger.info(`[agent-ui-kit] ${note.text} → ${note.where || note.tag}`);
|
|
129
|
+
response.statusCode = 204;
|
|
130
|
+
response.end();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Not swallowed: a note that failed to save must say so, or the
|
|
133
|
+
// person will believe it was taken into account.
|
|
134
|
+
server.config.logger.error(`[agent-ui-kit] not saved: ${String(error)}`);
|
|
135
|
+
response.statusCode = 500;
|
|
136
|
+
response.end();
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default agentUiKit;
|