@dorsk/yubisashi 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 +66 -0
- package/package.json +30 -0
- package/shell/app.css +436 -0
- package/shell/app.js +554 -0
- package/shell/index.html +46 -0
- package/skill/SKILL.md +50 -0
- package/src/cli.ts +259 -0
- package/src/format.ts +43 -0
- package/src/proxy.ts +89 -0
- package/src/server.ts +245 -0
- package/src/store.ts +113 -0
- package/src/transcript.ts +142 -0
package/shell/app.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
const frame = $('app');
|
|
3
|
+
const overlay = $('overlay');
|
|
4
|
+
const input = $('input');
|
|
5
|
+
|
|
6
|
+
const hash = new URLSearchParams(location.hash.slice(1));
|
|
7
|
+
const token = hash.get('token') ?? '';
|
|
8
|
+
|
|
9
|
+
const state = {
|
|
10
|
+
annotations: [],
|
|
11
|
+
transcript: [],
|
|
12
|
+
session: null,
|
|
13
|
+
picking: false,
|
|
14
|
+
hover: null,
|
|
15
|
+
hoverLabel: '',
|
|
16
|
+
/** @type {{ el: Element, info: any }[]} */
|
|
17
|
+
selection: [],
|
|
18
|
+
thread: null,
|
|
19
|
+
tab: 'chat',
|
|
20
|
+
/** annotation id → elements picked in this tab, so pins survive re-renders that change selectors */
|
|
21
|
+
refs: new Map(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function api(path, body) {
|
|
25
|
+
const res = await fetch(`api${path}`, {
|
|
26
|
+
method: body ? 'POST' : 'GET',
|
|
27
|
+
headers: { 'content-type': 'application/json', 'x-yubi-token': token },
|
|
28
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
|
31
|
+
return res.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const esc = (s) =>
|
|
35
|
+
String(s).replace(
|
|
36
|
+
/[&<>"']/g,
|
|
37
|
+
(c) => `&${{ '&': 'amp', '<': 'lt', '>': 'gt', '"': 'quot', "'": '#39' }[c]};`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function markdown(text) {
|
|
41
|
+
const blocks = text.split(/```[\w-]*\n?/);
|
|
42
|
+
return blocks
|
|
43
|
+
.map((block, i) => {
|
|
44
|
+
if (i % 2) return `<pre><code>${esc(block.replace(/\n$/, ''))}</code></pre>`;
|
|
45
|
+
return block
|
|
46
|
+
.trim()
|
|
47
|
+
.split(/\n{2,}/)
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((p) => {
|
|
50
|
+
const html = esc(p)
|
|
51
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
52
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
53
|
+
.replace(/^#{1,6} (.*)$/gm, '<strong>$1</strong>')
|
|
54
|
+
.replace(
|
|
55
|
+
/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
|
|
56
|
+
'<a href="$2" target="_blank" rel="noreferrer">$1</a>',
|
|
57
|
+
)
|
|
58
|
+
.replace(/\n/g, '<br>');
|
|
59
|
+
return `<p>${html}</p>`;
|
|
60
|
+
})
|
|
61
|
+
.join('');
|
|
62
|
+
})
|
|
63
|
+
.join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------- element capture
|
|
67
|
+
|
|
68
|
+
const shortFile = (file) => (file ? file.split('/').at(-1) : '');
|
|
69
|
+
const where = (loc) =>
|
|
70
|
+
loc?.file ? `${loc.file}:${loc.line}${loc.column ? `:${loc.column}` : ''}` : '';
|
|
71
|
+
|
|
72
|
+
function svelteMeta(el) {
|
|
73
|
+
for (let node = el; node; node = node.parentElement) {
|
|
74
|
+
if (node.__svelte_meta) return node.__svelte_meta;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stackOf(meta) {
|
|
80
|
+
const frames = [];
|
|
81
|
+
for (let p = meta?.parent; p && frames.length < 12; p = p.parent) {
|
|
82
|
+
if (p.type !== 'component' || p.file?.includes('.svelte-kit/generated')) continue;
|
|
83
|
+
frames.push({
|
|
84
|
+
type: p.type,
|
|
85
|
+
name: p.componentTag ? `<${p.componentTag}>` : undefined,
|
|
86
|
+
file: p.file,
|
|
87
|
+
line: p.line,
|
|
88
|
+
column: p.column,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return frames;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SCOPED_CLASS = /^(svelte|s)-[\w-]{5,}$/;
|
|
95
|
+
|
|
96
|
+
function selectorOf(el) {
|
|
97
|
+
const doc = el.ownerDocument;
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (
|
|
100
|
+
let node = el;
|
|
101
|
+
node && node.nodeType === 1 && node !== doc.documentElement;
|
|
102
|
+
node = node.parentElement
|
|
103
|
+
) {
|
|
104
|
+
for (const attr of ['data-testid', 'data-verb', 'data-test']) {
|
|
105
|
+
const value = node.getAttribute(attr);
|
|
106
|
+
if (value) {
|
|
107
|
+
parts.unshift(`[${attr}="${CSS.escape(value)}"]`);
|
|
108
|
+
return parts.join(' > ');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (node.id && !/\d{3,}/.test(node.id)) {
|
|
112
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
113
|
+
return parts.join(' > ');
|
|
114
|
+
}
|
|
115
|
+
let part = node.localName;
|
|
116
|
+
const classes = [...node.classList].filter((c) => !SCOPED_CLASS.test(c)).slice(0, 2);
|
|
117
|
+
if (classes.length) part += classes.map((c) => `.${CSS.escape(c)}`).join('');
|
|
118
|
+
const siblings = node.parentElement
|
|
119
|
+
? [...node.parentElement.children].filter((s) => s.localName === node.localName)
|
|
120
|
+
: [];
|
|
121
|
+
if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
|
122
|
+
parts.unshift(part);
|
|
123
|
+
if (parts.length >= 6) break;
|
|
124
|
+
}
|
|
125
|
+
return parts.join(' > ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function describe(el) {
|
|
129
|
+
const meta = svelteMeta(el);
|
|
130
|
+
const attrs = {};
|
|
131
|
+
for (const a of el.attributes) {
|
|
132
|
+
if (
|
|
133
|
+
a.name.startsWith('data-') ||
|
|
134
|
+
['role', 'aria-label', 'href', 'name', 'type', 'title', 'alt'].includes(a.name)
|
|
135
|
+
) {
|
|
136
|
+
attrs[a.name] = a.value.slice(0, 200);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const r = el.getBoundingClientRect();
|
|
140
|
+
const html = el.outerHTML.replace(/\s+/g, ' ');
|
|
141
|
+
return {
|
|
142
|
+
tag: el.localName,
|
|
143
|
+
selector: selectorOf(el),
|
|
144
|
+
text: (el.innerText ?? el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 100),
|
|
145
|
+
html: html.length > 500 ? `${html.slice(0, 500)}…` : html,
|
|
146
|
+
attrs,
|
|
147
|
+
rect: {
|
|
148
|
+
x: Math.round(r.x),
|
|
149
|
+
y: Math.round(r.y),
|
|
150
|
+
width: Math.round(r.width),
|
|
151
|
+
height: Math.round(r.height),
|
|
152
|
+
},
|
|
153
|
+
source: meta
|
|
154
|
+
? { file: meta.loc.file, line: meta.loc.line, column: meta.loc.column }
|
|
155
|
+
: undefined,
|
|
156
|
+
stack: stackOf(meta),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const label = (info) => {
|
|
161
|
+
const src = info.source ? `${shortFile(info.source.file)}:${info.source.line}` : info.selector;
|
|
162
|
+
return `<${info.tag}> ${src}`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- iframe wiring
|
|
166
|
+
|
|
167
|
+
function currentRoute() {
|
|
168
|
+
try {
|
|
169
|
+
const l = frame.contentWindow.location;
|
|
170
|
+
return l.pathname + l.search + l.hash;
|
|
171
|
+
} catch {
|
|
172
|
+
return '/';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function setPicking(on) {
|
|
177
|
+
state.picking = on;
|
|
178
|
+
$('pick').classList.toggle('on', on);
|
|
179
|
+
glass.hidden = !on;
|
|
180
|
+
if (!on) state.hover = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function onKey(e) {
|
|
184
|
+
const typing = e.target.closest?.(
|
|
185
|
+
'input, textarea, [contenteditable=""], [contenteditable="true"]',
|
|
186
|
+
);
|
|
187
|
+
if (e.key === 'Escape') {
|
|
188
|
+
if (state.picking) setPicking(false);
|
|
189
|
+
else if (state.selection.length) setSelection([]);
|
|
190
|
+
else if (state.thread) setThread(null);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!typing && (e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
setPicking(!state.picking);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Pick mode covers the app with a glass pane: disabled controls never receive clicks, and the
|
|
200
|
+
* app must not react to the pointing either. */
|
|
201
|
+
const glass = $('glass');
|
|
202
|
+
|
|
203
|
+
function elementAt(e) {
|
|
204
|
+
const doc = frame.contentDocument;
|
|
205
|
+
const r = frame.getBoundingClientRect();
|
|
206
|
+
return doc?.elementFromPoint(e.clientX - r.left, e.clientY - r.top) ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
glass.addEventListener('mousemove', (e) => {
|
|
210
|
+
const el = elementAt(e);
|
|
211
|
+
if (el === state.hover) return;
|
|
212
|
+
state.hover = el;
|
|
213
|
+
state.hoverLabel = el ? label(describe(el)) : '';
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
glass.addEventListener('click', (e) => {
|
|
217
|
+
const el = elementAt(e);
|
|
218
|
+
if (!el) return;
|
|
219
|
+
const entry = { el, info: describe(el) };
|
|
220
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
|
221
|
+
const i = state.selection.findIndex((s) => s.el === el);
|
|
222
|
+
setSelection(i >= 0 ? state.selection.filter((_, j) => j !== i) : [...state.selection, entry]);
|
|
223
|
+
} else {
|
|
224
|
+
setSelection([entry]);
|
|
225
|
+
setPicking(false);
|
|
226
|
+
}
|
|
227
|
+
setThread(null);
|
|
228
|
+
input.focus();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
glass.addEventListener(
|
|
232
|
+
'wheel',
|
|
233
|
+
(e) => {
|
|
234
|
+
const win = frame.contentWindow;
|
|
235
|
+
for (let node = elementAt(e); node; node = node.parentElement) {
|
|
236
|
+
const style = win.getComputedStyle(node);
|
|
237
|
+
if (/(auto|scroll)/.test(style.overflowY + style.overflowX)) {
|
|
238
|
+
const before = node.scrollTop + node.scrollLeft;
|
|
239
|
+
node.scrollBy(e.deltaX, e.deltaY);
|
|
240
|
+
if (node.scrollTop + node.scrollLeft !== before) return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
win.scrollBy(e.deltaX, e.deltaY);
|
|
244
|
+
},
|
|
245
|
+
{ passive: true },
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
function hook() {
|
|
249
|
+
const win = frame.contentWindow;
|
|
250
|
+
if (!win || win.__yubi) return;
|
|
251
|
+
win.__yubi = true;
|
|
252
|
+
win.addEventListener('keydown', onKey, true);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
frame.addEventListener('load', hook);
|
|
256
|
+
setInterval(() => {
|
|
257
|
+
hook();
|
|
258
|
+
const route = currentRoute();
|
|
259
|
+
if ($('route').textContent !== route) {
|
|
260
|
+
$('route').textContent = route;
|
|
261
|
+
hash.set('route', route);
|
|
262
|
+
history.replaceState(null, '', `#${hash}`);
|
|
263
|
+
}
|
|
264
|
+
renderPins();
|
|
265
|
+
}, 400);
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------- overlay
|
|
268
|
+
|
|
269
|
+
function box(rect, cls, text) {
|
|
270
|
+
const el = document.createElement('div');
|
|
271
|
+
el.className = `box ${cls}`;
|
|
272
|
+
Object.assign(el.style, {
|
|
273
|
+
left: `${rect.x}px`,
|
|
274
|
+
top: `${rect.y}px`,
|
|
275
|
+
width: `${rect.width}px`,
|
|
276
|
+
height: `${rect.height}px`,
|
|
277
|
+
});
|
|
278
|
+
if (text) {
|
|
279
|
+
const tag = document.createElement('span');
|
|
280
|
+
tag.className = 'tag';
|
|
281
|
+
tag.textContent = text;
|
|
282
|
+
el.append(tag);
|
|
283
|
+
}
|
|
284
|
+
return el;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let pins = [];
|
|
288
|
+
|
|
289
|
+
function renderPins() {
|
|
290
|
+
const route = currentRoute();
|
|
291
|
+
pins = [];
|
|
292
|
+
let doc;
|
|
293
|
+
try {
|
|
294
|
+
doc = frame.contentDocument;
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
for (const a of state.annotations) {
|
|
299
|
+
if (a.status !== 'open' || a.route !== route || !a.targets.length) continue;
|
|
300
|
+
let el = state.refs.get(a.id)?.find((e) => e.isConnected);
|
|
301
|
+
if (!el) {
|
|
302
|
+
try {
|
|
303
|
+
el = doc?.querySelector(a.targets[0].selector);
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
if (el) pins.push({ id: a.id, el });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function draw() {
|
|
311
|
+
const layers = [];
|
|
312
|
+
const hover = state.hover;
|
|
313
|
+
if (state.picking && hover?.isConnected && !state.selection.some((s) => s.el === hover)) {
|
|
314
|
+
layers.push(box(hover.getBoundingClientRect(), 'hover', state.hoverLabel));
|
|
315
|
+
}
|
|
316
|
+
for (const s of state.selection) {
|
|
317
|
+
if (s.el.isConnected) layers.push(box(s.el.getBoundingClientRect(), 'selected', label(s.info)));
|
|
318
|
+
}
|
|
319
|
+
for (const p of pins) {
|
|
320
|
+
if (!p.el.isConnected) continue;
|
|
321
|
+
const r = p.el.getBoundingClientRect();
|
|
322
|
+
const pin = document.createElement('div');
|
|
323
|
+
pin.className = 'pin';
|
|
324
|
+
pin.textContent = p.id;
|
|
325
|
+
pin.style.left = `${r.x + r.width}px`;
|
|
326
|
+
pin.style.top = `${r.y}px`;
|
|
327
|
+
pin.onclick = () => {
|
|
328
|
+
switchTab('comments');
|
|
329
|
+
setThread(p.id);
|
|
330
|
+
};
|
|
331
|
+
layers.push(pin);
|
|
332
|
+
}
|
|
333
|
+
overlay.replaceChildren(...layers);
|
|
334
|
+
requestAnimationFrame(draw);
|
|
335
|
+
}
|
|
336
|
+
requestAnimationFrame(draw);
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------- panel
|
|
339
|
+
|
|
340
|
+
function switchTab(tab) {
|
|
341
|
+
state.tab = tab;
|
|
342
|
+
for (const b of document.querySelectorAll('.tabs button'))
|
|
343
|
+
b.classList.toggle('active', b.dataset.tab === tab);
|
|
344
|
+
$('chat').hidden = tab !== 'chat';
|
|
345
|
+
$('comments').hidden = tab !== 'comments';
|
|
346
|
+
}
|
|
347
|
+
for (const b of document.querySelectorAll('.tabs button'))
|
|
348
|
+
b.onclick = () => switchTab(b.dataset.tab);
|
|
349
|
+
|
|
350
|
+
function setSelection(selection) {
|
|
351
|
+
state.selection = selection;
|
|
352
|
+
renderContext();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function setThread(id) {
|
|
356
|
+
state.thread = id;
|
|
357
|
+
renderContext();
|
|
358
|
+
renderComments();
|
|
359
|
+
if (id) input.focus();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderContext() {
|
|
363
|
+
const chips = state.selection.map((s, i) => {
|
|
364
|
+
const chip = document.createElement('span');
|
|
365
|
+
chip.className = 'chip';
|
|
366
|
+
chip.title = [where(s.info.source), ...s.info.stack.map((f) => `${f.name ?? ''} ${where(f)}`)]
|
|
367
|
+
.filter(Boolean)
|
|
368
|
+
.join('\n');
|
|
369
|
+
chip.textContent = label(s.info);
|
|
370
|
+
const x = document.createElement('button');
|
|
371
|
+
x.type = 'button';
|
|
372
|
+
x.textContent = '×';
|
|
373
|
+
x.onclick = () => setSelection(state.selection.filter((_, j) => j !== i));
|
|
374
|
+
chip.append(x);
|
|
375
|
+
return chip;
|
|
376
|
+
});
|
|
377
|
+
if (state.thread) {
|
|
378
|
+
const chip = document.createElement('span');
|
|
379
|
+
chip.className = 'chip';
|
|
380
|
+
chip.textContent = `replying to #${state.thread}`;
|
|
381
|
+
const x = document.createElement('button');
|
|
382
|
+
x.type = 'button';
|
|
383
|
+
x.textContent = '×';
|
|
384
|
+
x.onclick = () => setThread(null);
|
|
385
|
+
chip.append(x);
|
|
386
|
+
chips.unshift(chip);
|
|
387
|
+
}
|
|
388
|
+
$('context').replaceChildren(...chips);
|
|
389
|
+
input.placeholder = state.thread
|
|
390
|
+
? `Reply to #${state.thread}…`
|
|
391
|
+
: state.selection.length
|
|
392
|
+
? 'What should change here?'
|
|
393
|
+
: 'Message the agent…';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function nearBottom(el) {
|
|
397
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function entryNode(e) {
|
|
401
|
+
const el = document.createElement('div');
|
|
402
|
+
el.className = `msg ${e.kind}`;
|
|
403
|
+
if (e.kind === 'assistant') el.innerHTML = markdown(e.text);
|
|
404
|
+
else el.textContent = e.kind === 'event' ? `· ${e.text} ·` : e.text;
|
|
405
|
+
return el;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderChat(fresh) {
|
|
409
|
+
const feed = $('chat');
|
|
410
|
+
const stick = nearBottom(feed);
|
|
411
|
+
if (!fresh) {
|
|
412
|
+
if (!state.session) {
|
|
413
|
+
feed.innerHTML =
|
|
414
|
+
'<p class="empty">No Claude session attached.<br>Start <code>yubi up</code> from inside the agent\'s session or pass <code>--session <id></code>.</p>';
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
feed.replaceChildren(...state.transcript.map(entryNode));
|
|
418
|
+
} else {
|
|
419
|
+
feed.append(...fresh.map(entryNode));
|
|
420
|
+
}
|
|
421
|
+
if (stick || !fresh) feed.scrollTop = feed.scrollHeight;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderComments() {
|
|
425
|
+
const feed = $('comments');
|
|
426
|
+
const open = state.annotations.filter((a) => a.status === 'open').length;
|
|
427
|
+
$('count').textContent = open ? `(${open})` : '';
|
|
428
|
+
if (!state.annotations.length) {
|
|
429
|
+
feed.innerHTML =
|
|
430
|
+
'<p class="empty">Press <code>C</code>, click an element, and say what should change.</p>';
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const cards = [...state.annotations].reverse().map((a) => {
|
|
434
|
+
const card = document.createElement('article');
|
|
435
|
+
card.className = `card${a.status === 'open' ? '' : ' closed'}${state.thread === a.id ? ' focus' : ''}`;
|
|
436
|
+
const targets = a.targets
|
|
437
|
+
.map(
|
|
438
|
+
(t) =>
|
|
439
|
+
`<div class="where" title="${esc(t.stack.map((f) => `${f.name ?? ''} ${where(f)}`).join('\n'))}">${esc(label(t))}</div>`,
|
|
440
|
+
)
|
|
441
|
+
.join('');
|
|
442
|
+
const thread = a.thread
|
|
443
|
+
.map(
|
|
444
|
+
(m) =>
|
|
445
|
+
`<div class="${m.from}">${m.from === 'agent' ? markdown(m.text) : esc(m.text)}</div>`,
|
|
446
|
+
)
|
|
447
|
+
.join('');
|
|
448
|
+
card.innerHTML = `
|
|
449
|
+
<header><span class="id">#${a.id}</span><span class="badge ${a.status}">${a.status}</span>
|
|
450
|
+
<span class="where">${esc(a.route)}</span></header>
|
|
451
|
+
${targets}<div class="thread">${thread}</div>
|
|
452
|
+
<div class="actions"></div>`;
|
|
453
|
+
const actions = card.querySelector('.actions');
|
|
454
|
+
const action = (text, fn) => {
|
|
455
|
+
const b = document.createElement('button');
|
|
456
|
+
b.type = 'button';
|
|
457
|
+
b.textContent = text;
|
|
458
|
+
b.onclick = fn;
|
|
459
|
+
actions.append(b);
|
|
460
|
+
};
|
|
461
|
+
action('Reply', () => setThread(a.id));
|
|
462
|
+
if (a.status === 'open')
|
|
463
|
+
action('Resolve', () => api(`/annotations/${a.id}/messages`, { status: 'resolved' }));
|
|
464
|
+
else action('Reopen', () => api(`/annotations/${a.id}/messages`, { status: 'open' }));
|
|
465
|
+
return card;
|
|
466
|
+
});
|
|
467
|
+
feed.replaceChildren(...cards);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function setListening(on) {
|
|
471
|
+
const el = $('status');
|
|
472
|
+
el.classList.toggle('on', on);
|
|
473
|
+
el.textContent = on ? 'agent listening' : 'agent busy';
|
|
474
|
+
el.title = on
|
|
475
|
+
? 'The agent is waiting for your messages.'
|
|
476
|
+
: 'Messages queue until the agent runs `yubi wait` again.';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
$('composer').addEventListener('submit', async (e) => {
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
const text = input.value.trim();
|
|
482
|
+
if (!text) return;
|
|
483
|
+
input.disabled = true;
|
|
484
|
+
try {
|
|
485
|
+
if (state.thread) {
|
|
486
|
+
await api(`/annotations/${state.thread}/messages`, { text });
|
|
487
|
+
} else {
|
|
488
|
+
const picked = state.selection;
|
|
489
|
+
const created = await api('/annotations', {
|
|
490
|
+
comment: text,
|
|
491
|
+
route: currentRoute(),
|
|
492
|
+
viewport: { width: frame.clientWidth, height: frame.clientHeight },
|
|
493
|
+
targets: picked.map((s) => s.info),
|
|
494
|
+
});
|
|
495
|
+
if (picked.length)
|
|
496
|
+
state.refs.set(
|
|
497
|
+
created.id,
|
|
498
|
+
picked.map((s) => s.el),
|
|
499
|
+
);
|
|
500
|
+
setSelection([]);
|
|
501
|
+
}
|
|
502
|
+
input.value = '';
|
|
503
|
+
} catch (err) {
|
|
504
|
+
alert(`yubisashi: ${err.message}`);
|
|
505
|
+
} finally {
|
|
506
|
+
input.disabled = false;
|
|
507
|
+
input.focus();
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
input.addEventListener('keydown', (e) => {
|
|
512
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
$('composer').requestSubmit();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
document.addEventListener('keydown', onKey, true);
|
|
518
|
+
$('pick').onclick = () => setPicking(!state.picking);
|
|
519
|
+
$('reload').onclick = () => frame.contentWindow.location.reload();
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------- boot
|
|
522
|
+
|
|
523
|
+
async function boot() {
|
|
524
|
+
if (!token) {
|
|
525
|
+
document.body.innerHTML =
|
|
526
|
+
'<p class="empty" style="margin:auto">Missing token. Open the URL printed by <code>yubi up</code> (or run <code>yubi url</code>).</p>';
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const initial = await api('/state');
|
|
530
|
+
state.annotations = initial.annotations;
|
|
531
|
+
state.transcript = initial.transcript;
|
|
532
|
+
state.session = initial.session;
|
|
533
|
+
$('open').href = initial.target;
|
|
534
|
+
frame.src = hash.get('route') || '/';
|
|
535
|
+
setListening(initial.listening);
|
|
536
|
+
renderChat();
|
|
537
|
+
renderComments();
|
|
538
|
+
renderContext();
|
|
539
|
+
|
|
540
|
+
const events = new EventSource(`api/events?token=${encodeURIComponent(token)}`);
|
|
541
|
+
events.addEventListener('listening', (e) => setListening(JSON.parse(e.data)));
|
|
542
|
+
events.addEventListener('annotations', (e) => {
|
|
543
|
+
state.annotations = JSON.parse(e.data);
|
|
544
|
+
renderComments();
|
|
545
|
+
renderPins();
|
|
546
|
+
});
|
|
547
|
+
events.addEventListener('transcript', (e) => {
|
|
548
|
+
const fresh = JSON.parse(e.data);
|
|
549
|
+
state.transcript.push(...fresh);
|
|
550
|
+
renderChat(fresh);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
boot();
|
package/shell/index.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>yubisashi</title>
|
|
7
|
+
<link rel="stylesheet" href="app.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main id="stage">
|
|
11
|
+
<header id="bar">
|
|
12
|
+
<button id="pick" type="button" title="Comment on elements (C)">
|
|
13
|
+
<span class="glyph">⌖</span> Comment
|
|
14
|
+
</button>
|
|
15
|
+
<span id="route"></span>
|
|
16
|
+
<button id="reload" type="button" title="Reload the app">↻</button>
|
|
17
|
+
<a id="open" href="/" target="_blank" rel="noreferrer" title="Open the app on its own">↗</a>
|
|
18
|
+
</header>
|
|
19
|
+
<div id="viewport">
|
|
20
|
+
<iframe id="app" title="app under review"></iframe>
|
|
21
|
+
<div id="overlay"></div>
|
|
22
|
+
<div id="glass" hidden></div>
|
|
23
|
+
</div>
|
|
24
|
+
</main>
|
|
25
|
+
<aside id="panel">
|
|
26
|
+
<header>
|
|
27
|
+
<nav class="tabs">
|
|
28
|
+
<button type="button" data-tab="chat" class="active">Conversation</button>
|
|
29
|
+
<button type="button" data-tab="comments">Comments <span id="count"></span></button>
|
|
30
|
+
</nav>
|
|
31
|
+
<span id="status" title=""></span>
|
|
32
|
+
</header>
|
|
33
|
+
<section id="chat" class="feed"></section>
|
|
34
|
+
<section id="comments" class="feed" hidden></section>
|
|
35
|
+
<form id="composer">
|
|
36
|
+
<div id="context"></div>
|
|
37
|
+
<textarea id="input" rows="3" placeholder="Message the agent…"></textarea>
|
|
38
|
+
<div class="row">
|
|
39
|
+
<span id="hint">C to point at elements · Shift+click to add · Enter to send</span>
|
|
40
|
+
<button type="submit">Send</button>
|
|
41
|
+
</div>
|
|
42
|
+
</form>
|
|
43
|
+
</aside>
|
|
44
|
+
<script type="module" src="app.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yubisashi
|
|
3
|
+
description: Use when the user wants to review a running web app visually and send you feedback by pointing at elements — "let me comment on the UI", "start a review session", "I'll show you what I don't like". Starts the dev server behind the yubisashi shell (app + conversation panel side by side) and wires the user's comments into this session.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# yubisashi: UI review loop
|
|
7
|
+
|
|
8
|
+
The user points at elements in their running app, writes a comment, and it arrives here with
|
|
9
|
+
the element's source `file:line`, the component chain that rendered it, a selector and an HTML
|
|
10
|
+
snippet. Their view also streams this conversation next to the app.
|
|
11
|
+
|
|
12
|
+
## Start
|
|
13
|
+
|
|
14
|
+
1. Start the dev server behind yubisashi as a **background** command, from the app's directory:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
yubi up --target http://localhost:5173 -- npm run dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`--target` is the dev server's URL. Leave out `-- <cmd>` if the server is already running.
|
|
21
|
+
It listens on 127.0.0.1:4780 by default (`--port`, `--host`).
|
|
22
|
+
|
|
23
|
+
2. Run `yubi url` and give the user the URL it prints (it carries the access token).
|
|
24
|
+
|
|
25
|
+
3. Start listening as a **background** command:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
yubi wait
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
It exits when the user sends something, and its output is their messages. The harness
|
|
32
|
+
wakes you up when it exits, so you don't need to poll.
|
|
33
|
+
|
|
34
|
+
## Each time `yubi wait` returns
|
|
35
|
+
|
|
36
|
+
1. Read every message. `source:` is where the element is written; `rendered by:` lists the
|
|
37
|
+
components that rendered it, innermost first. These are the files to look at.
|
|
38
|
+
2. Do the work. The dev server hot-reloads, so the user sees the change in the shell.
|
|
39
|
+
3. Answer each thread by id:
|
|
40
|
+
- `yubi resolve <id> "what changed"` when it's done
|
|
41
|
+
- `yubi reply <id> "question or progress"` to keep the thread open
|
|
42
|
+
- `yubi dismiss <id> "why"` to decline
|
|
43
|
+
4. Start `yubi wait` in the background again. Until you do, the panel shows "agent busy" and
|
|
44
|
+
messages queue up.
|
|
45
|
+
|
|
46
|
+
Messages without a target are general chat from the panel. Answer them in your normal
|
|
47
|
+
response, since the user reads this conversation in the panel.
|
|
48
|
+
|
|
49
|
+
`yubi list` shows open comments. Stop the loop only when the user says they're done, then stop
|
|
50
|
+
the `yubi up` background task.
|