agello 0.2.1 → 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/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  - Live session status, tool activity, and prompts queued while the agent is busy (same order as the terminal)
7
7
  - Conversation saved per pane in the browser, kept across reloads
8
8
  - Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
9
+ - Annotate: point at an element on that screen and send a request about it, with its CSS selector
9
10
  - Embeddable `<agent-bridge>` web component
10
11
 
11
12
  Currently supports **Claude Code** sessions.
@@ -59,6 +60,18 @@ Messages sent from the page arrive in the agent as:
59
60
 
60
61
  The approve / reject buttons send `action=approve` / `action=reject`.
61
62
 
63
+ ### Annotate
64
+
65
+ The `주석` button on the screen panel turns on annotate mode: hovering tints the element under the pointer (with its tag and size), clicking outlines it and opens an input next to it. The wheel still scrolls the page, and the outline follows the element as the page changes. The request arrives as:
66
+
67
+ ```
68
+ [browser] action=annotate make this red / and bolder
69
+ 대상: #app > main > button:nth-of-type(2)
70
+ 요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
71
+ ```
72
+
73
+ Selector: nearest unique `id` / `data-testid` ancestor, then `tag:nth-of-type` steps (light DOM only). Coordinates are CSS px of the viewport. Off while presenting and while "내 조작" is on.
74
+
62
75
  ### Commands
63
76
 
64
77
  | Command | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Talk to a coding agent in a herdr pane from the browser: chat, live status, and a shared browser screen.",
5
5
  "keywords": [
6
6
  "herdr",
package/src/screencast.ts CHANGED
@@ -275,5 +275,68 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
275
275
  return !!r && !r.exceptionDetails;
276
276
  }
277
277
 
278
- return { handle, input, describe, setClip, evaluate };
278
+ // Annotation: which element is at a point (hover / click) or where a
279
+ // selected element is now (by selector). Coordinates are CSS px of the
280
+ // visible viewport, same as the full screencast frame. Not while cropped.
281
+ async function inspect(q: InspectQuery): Promise<Inspected | null> {
282
+ if (clip) return null;
283
+ const r = await request("Runtime.evaluate", {
284
+ expression: `(${INSPECT_JS})(${JSON.stringify(q)})`,
285
+ returnByValue: true,
286
+ });
287
+ return r && !r.exceptionDetails ? (r.result?.value ?? null) : null;
288
+ }
289
+
290
+ return { handle, input, describe, setClip, evaluate, inspect };
279
291
  }
292
+
293
+ export type InspectQuery = { x?: number; y?: number; selector?: string; full?: boolean };
294
+ export type Inspected = {
295
+ selector: string;
296
+ label: string; // tag#id.class, for the hover tooltip
297
+ rect: { x: number; y: number; w: number; h: number };
298
+ text?: string; // full only
299
+ url?: string; // full only
300
+ };
301
+
302
+ // Runs in the page. Selector: nearest unique id / data-testid ancestor, then
303
+ // tag:nth-of-type steps down to the element (light DOM only).
304
+ const INSPECT_JS = String((q: InspectQuery) => {
305
+ let el: Element | null = null;
306
+ if (q.selector) {
307
+ try {
308
+ el = document.querySelector(q.selector);
309
+ } catch {}
310
+ } else el = document.elementFromPoint(q.x ?? 0, q.y ?? 0);
311
+ if (!el || el === document.documentElement) return null;
312
+ const esc = CSS.escape;
313
+ const unique = (s: string) => document.querySelectorAll(s).length === 1;
314
+ const step = (e: Element): [string, boolean] => {
315
+ if (e.id && unique(`#${esc(e.id)}`)) return [`#${esc(e.id)}`, true];
316
+ for (const a of ["data-testid", "data-test", "data-cy"]) {
317
+ const v = e.getAttribute(a);
318
+ if (v && unique(`[${a}="${esc(v)}"]`)) return [`[${a}="${esc(v)}"]`, true];
319
+ }
320
+ const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.localName === e.localName) : [];
321
+ return [same.length > 1 ? `${e.localName}:nth-of-type(${same.indexOf(e) + 1})` : e.localName, false];
322
+ };
323
+ const parts: string[] = [];
324
+ for (let e: Element | null = el; e && e !== document.documentElement; e = e.parentElement) {
325
+ const [s, anchor] = step(e);
326
+ parts.unshift(s);
327
+ if (anchor || e === document.body) break;
328
+ }
329
+ const cls = [...el.classList].slice(0, 2).map((c) => `.${c}`).join("");
330
+ const r = el.getBoundingClientRect();
331
+ const out: any = {
332
+ selector: parts.join(" > "),
333
+ label: `${el.localName}${el.id ? `#${el.id}` : ""}${cls}`,
334
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
335
+ };
336
+ if (q.full) {
337
+ const t = (el as HTMLInputElement).value || el.getAttribute("aria-label") || el.getAttribute("alt") || el.textContent || "";
338
+ out.text = t.replace(/\s+/g, " ").trim().slice(0, 80);
339
+ out.url = location.href;
340
+ }
341
+ return out;
342
+ });
package/src/server.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  // GET /status session liveness (herdr agent get + pane get)
6
6
  // GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
7
7
  // POST /send {action, text} -> herdr agent prompt
8
+ // (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
8
9
  // GET /screen SSE: terminal-browser screen frames (CDP screencast)
9
- // WS /input user control: CDP Input.* commands forwarded to the page
10
+ // WS /input user control: CDP Input.* commands forwarded to the page;
11
+ // {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
10
12
  // GET /present presentation state
11
13
  // POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
12
14
  // ({stop: true, from: "viewer"}: ended from the page, the agent is told
@@ -19,7 +21,7 @@ import { homedir } from "node:os";
19
21
  import { join } from "node:path";
20
22
  import { runJson } from "./run.ts";
21
23
  import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
22
- import { createScreencast, type Rect } from "./screencast.ts";
24
+ import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
23
25
 
24
26
  export type ServerOptions = {
25
27
  port: number;
@@ -94,6 +96,21 @@ export function parseRect(r: any): Rect | null {
94
96
  return { x, y, w, h };
95
97
  }
96
98
 
99
+ // Annotation prompt: the viewer's request plus the element it points at, in
100
+ // 3 lines (request / selector / element summary).
101
+ export function formatAnnotation(text: string, t: any): string | null {
102
+ if (!t || typeof t.selector !== "string" || !t.selector || t.selector.length > 1000) return null;
103
+ const clean = (v: unknown, n: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
104
+ const request = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(" / ");
105
+ const r = t.rect ?? {};
106
+ const [x, y, w, h] = [r.x, r.y, r.w, r.h].map((v) => Math.round(Number(v) || 0));
107
+ const label = clean(t.label, 120);
108
+ const snippet = clean(t.text, 80);
109
+ const url = clean(t.url, 300);
110
+ const info = [`요소: ${label}${snippet ? ` "${snippet}"` : ""}`, `위치 ${x},${y} 크기 ${w}x${h}`, url].filter(Boolean);
111
+ return [request, `대상: ${clean(t.selector, 1000)}`, info.join(" · ")].join("\n");
112
+ }
113
+
97
114
  export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
98
115
 
99
116
  type Status =
@@ -465,9 +482,14 @@ export async function startServer(opts: ServerOptions) {
465
482
  }
466
483
 
467
484
  async function sendPrompt(req: Request): Promise<Response> {
468
- const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
485
+ const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; target?: Inspected };
469
486
  const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
470
487
  let text = data.text ?? "";
488
+ if (action === "annotate") {
489
+ const t = formatAnnotation(text, data.target);
490
+ if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
491
+ text = t;
492
+ }
471
493
  // Raised hand: stop the script at once (no agent round trip) and tell the
472
494
  // agent where it stopped.
473
495
  if (action === "hand-raise") {
@@ -515,8 +537,24 @@ export async function startServer(opts: ServerOptions) {
515
537
  port: opts.port,
516
538
  idleTimeout: 0, // keep SSE open
517
539
  websocket: {
518
- message(_ws, msg) {
519
- screen.input(String(msg));
540
+ async message(ws, msg) {
541
+ const raw = String(msg);
542
+ let m: any;
543
+ try {
544
+ m = JSON.parse(raw);
545
+ } catch {
546
+ return;
547
+ }
548
+ if (m?.inspect) {
549
+ const q = m.inspect;
550
+ const query =
551
+ typeof q.selector === "string"
552
+ ? { selector: q.selector.slice(0, 1000), full: !!q.full }
553
+ : { x: Number(q.x) || 0, y: Number(q.y) || 0, full: !!q.full };
554
+ ws.send(JSON.stringify({ id: m.id, result: await screen.inspect(query) }));
555
+ return;
556
+ }
557
+ screen.input(raw);
520
558
  },
521
559
  },
522
560
  async fetch(req, server) {
@@ -12,6 +12,9 @@
12
12
  // no-history do not persist chat in localStorage (default: persisted per server + pane)
13
13
  // screen show live terminal-browser screen panel (side by side, stacked when narrow)
14
14
  // with a toggle that lets the viewer control the browser (mouse, wheel, keyboard)
15
+ // and an annotate toggle: hovering tints the element under the pointer, clicking
16
+ // outlines it and opens an input; the request goes to the agent with the element's
17
+ // CSS selector (action=annotate). The wheel still scrolls the page.
15
18
  //
16
19
  // Theming (CSS custom properties on the element or an ancestor)
17
20
  // --ab-bg --ab-panel --ab-text --ab-muted --ab-line --ab-accent --ab-user --ab-font
@@ -41,7 +44,7 @@
41
44
  // is working (e.g. answering a question), a small loader is shown.
42
45
  //
43
46
  // Methods
44
- // el.send(text, action = 'message') -> Promise<{ok, error?}>
47
+ // el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: e.g. {target} for annotate)
45
48
  // el.clearHistory() clear saved chat for the current pane
46
49
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
47
50
 
@@ -72,7 +75,7 @@ const SCREEN_REASONS = {
72
75
  };
73
76
  const ACTIONS = {
74
77
  message: "메시지", approve: "승인", reject: "거절",
75
- "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
78
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
76
79
  };
77
80
 
78
81
  const CSS = `
@@ -175,6 +178,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
175
178
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
176
179
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
177
180
 
181
+ /* annotate: hover tint, selected outline, request input next to the element */
182
+ .annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
183
+ .annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
184
+ .viewport.annot img { cursor: crosshair; }
185
+ .hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
186
+ .hl.on { display: block; }
187
+ .hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
188
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
189
+ .hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
190
+ .hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
191
+ padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
192
+ background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
193
+ .hl-label.on { display: block; }
194
+ .note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
195
+ background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
196
+ animation: capIn .18s ease; }
197
+ .note[hidden] { display: none; }
198
+ .note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
199
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
200
+ .note textarea { min-height: 52px; font-size: 14px; }
201
+ .note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
202
+ .note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
203
+ @media (prefers-reduced-motion: reduce) { .note { animation: none; } }
204
+
178
205
  /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
179
206
  container-type would make the host the containing block of the fixed layer, so drop it. */
180
207
  :host([presenting]) { container-type: normal; }
@@ -336,9 +363,12 @@ const TEMPLATE = `
336
363
  </section>
337
364
  <aside class="screen" part="screen">
338
365
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
366
+ <button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
339
367
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
340
368
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
341
- <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="captions" part="captions" aria-live="polite"></div>
369
+ <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
370
+ <form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
371
+ <div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
342
372
  <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
343
373
  <div class="hand-wrap">
344
374
  <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
@@ -358,7 +388,14 @@ class AgentBridge extends HTMLElement {
358
388
  #root = this.attachShadow({ mode: "open" });
359
389
  #es = null;
360
390
  #screenEs = null;
361
- #controlWs = null;
391
+ #ws = null; // /input: control input and annotate inspect queries
392
+ #control = false;
393
+ #annot = false;
394
+ #hover = null; // inspected element under the pointer (annotate)
395
+ #hoverPt = null;
396
+ #sel = null; // selected element (annotate)
397
+ #inspectId = 0;
398
+ #inspectWait = new Map();
362
399
  #frame = { w: 0, h: 0 };
363
400
  #moveQueued = null;
364
401
  #status = null;
@@ -383,6 +420,8 @@ class AgentBridge extends HTMLElement {
383
420
  controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
384
421
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
385
422
  busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
423
+ annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
424
+ note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
386
425
  };
387
426
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
388
427
  this.#bindGrip();
@@ -394,6 +433,7 @@ class AgentBridge extends HTMLElement {
394
433
  });
395
434
  loadMarkdown().then((md) => (this.#md = md));
396
435
  this.#bindControl();
436
+ this.#bindAnnotate();
397
437
  this.#bindHand();
398
438
  this.$.clearBtn.addEventListener("click", () => this.clearHistory());
399
439
  }
@@ -413,6 +453,7 @@ class AgentBridge extends HTMLElement {
413
453
  this.#screenEs?.close();
414
454
  this.#screenEs = null;
415
455
  this.#setControl(false);
456
+ this.#setAnnotate(false);
416
457
  }
417
458
  attributeChangedCallback(name) {
418
459
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
@@ -420,12 +461,12 @@ class AgentBridge extends HTMLElement {
420
461
  if (name === "screen" && this.isConnected) this.#syncScreen();
421
462
  }
422
463
 
423
- async send(text, action = "message") {
464
+ async send(text, action = "message", extra = {}) {
424
465
  try {
425
466
  const res = await fetch(`${this.server}/send`, {
426
467
  method: "POST",
427
468
  headers: { "Content-Type": "application/json" },
428
- body: JSON.stringify({ action, text }),
469
+ body: JSON.stringify({ ...extra, action, text }),
429
470
  });
430
471
  return await res.json();
431
472
  } catch {
@@ -491,7 +532,7 @@ class AgentBridge extends HTMLElement {
491
532
 
492
533
  #syncScreen() {
493
534
  const want = this.hasAttribute("screen") || this.#present.on;
494
- if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
535
+ if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
495
536
  if (this.#screenEs) return;
496
537
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
497
538
  es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
@@ -500,6 +541,7 @@ class AgentBridge extends HTMLElement {
500
541
  this.#frame = { w: f.w, h: f.h };
501
542
  this.$.img.src = `data:image/jpeg;base64,${f.data}`;
502
543
  this.$.viewport.classList.add("has");
544
+ if (this.#annot) this.#annotFrame();
503
545
  });
504
546
  es.onerror = () => this.#renderScreenMeta({ connected: false });
505
547
  }
@@ -513,7 +555,7 @@ class AgentBridge extends HTMLElement {
513
555
  this.#emit("agent-present", this.#present);
514
556
  this.#renderBusy();
515
557
  if (on === was) return;
516
- if (on) this.#setControl(false);
558
+ if (on) { this.#setControl(false); this.#setAnnotate(false); }
517
559
  else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
518
560
  this.#syncScreen();
519
561
  // FLIP: grow from (or shrink back to) the side panel
@@ -652,24 +694,43 @@ class AgentBridge extends HTMLElement {
652
694
 
653
695
  // ---------- user control ----------
654
696
 
655
- #setControl(on) {
656
- if (on && !this.#controlWs) {
697
+ // One socket for both modes; open while control or annotate is on.
698
+ #syncWs() {
699
+ const want = this.#control || this.#annot;
700
+ if (want && !this.#ws) {
657
701
  const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
658
- ws.onclose = () => { if (this.#controlWs === ws) this.#setControl(false); };
659
- this.#controlWs = ws;
660
- } else if (!on && this.#controlWs) {
661
- const ws = this.#controlWs;
662
- this.#controlWs = null;
702
+ ws.onmessage = (e) => {
703
+ let m;
704
+ try { m = JSON.parse(e.data); } catch { return; }
705
+ this.#inspectWait.get(m.id)?.(m.result ?? null);
706
+ };
707
+ ws.onclose = () => {
708
+ if (this.#ws !== ws) return;
709
+ this.#ws = null;
710
+ this.#setControl(false);
711
+ this.#setAnnotate(false);
712
+ };
713
+ this.#ws = ws;
714
+ } else if (!want && this.#ws) {
715
+ const ws = this.#ws;
716
+ this.#ws = null;
663
717
  ws.close();
664
718
  }
665
- this.$.controlBtn.setAttribute("aria-checked", String(!!this.#controlWs));
666
- this.$.controlBtn.querySelector(".state-label").textContent = this.#controlWs ? "ON" : "OFF";
667
- this.$.viewport.classList.toggle("control", !!this.#controlWs);
668
- if (this.#controlWs) this.$.kbd.focus();
719
+ if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
720
+ }
721
+
722
+ #setControl(on) {
723
+ this.#control = !!on && !this.#present.on;
724
+ if (this.#control) this.#setAnnotate(false);
725
+ this.#syncWs();
726
+ this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
727
+ this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
728
+ this.$.viewport.classList.toggle("control", this.#control);
729
+ if (this.#control) this.$.kbd.focus();
669
730
  }
670
731
 
671
732
  #cdp(method, params) {
672
- const ws = this.#controlWs;
733
+ const ws = this.#ws;
673
734
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
674
735
  }
675
736
 
@@ -683,11 +744,11 @@ class AgentBridge extends HTMLElement {
683
744
 
684
745
  #bindControl() {
685
746
  const { img, kbd, controlBtn } = this.$;
686
- const on = () => !!this.#controlWs && this.#frame.w > 0;
747
+ const on = () => this.#control && this.#frame.w > 0;
687
748
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
688
749
  const BUTTONS = ["left", "middle", "right"];
689
750
 
690
- controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
751
+ controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
691
752
 
692
753
  img.addEventListener("mousedown", (e) => {
693
754
  if (!on()) return;
@@ -715,7 +776,7 @@ class AgentBridge extends HTMLElement {
715
776
  });
716
777
  });
717
778
  img.addEventListener("wheel", (e) => {
718
- if (!on()) return;
779
+ if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
719
780
  e.preventDefault();
720
781
  this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
721
782
  modifiers: mods(e) });
@@ -753,6 +814,163 @@ class AgentBridge extends HTMLElement {
753
814
  });
754
815
  }
755
816
 
817
+ // ---------- annotate ----------
818
+
819
+ #setAnnotate(on) {
820
+ on = !!on && !this.#present.on;
821
+ if (on === this.#annot) return;
822
+ this.#annot = on;
823
+ if (on) this.#setControl(false);
824
+ else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
825
+ this.#syncWs();
826
+ this.$.annotBtn.setAttribute("aria-pressed", String(on));
827
+ this.$.viewport.classList.toggle("annot", on);
828
+ this.#drawHl();
829
+ }
830
+
831
+ // Element at a frame point ({x, y}) or by selector; null if unavailable.
832
+ #inspect(q, full = false) {
833
+ const ws = this.#ws;
834
+ if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
835
+ const id = ++this.#inspectId;
836
+ return new Promise((resolve) => {
837
+ const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
838
+ const t = setTimeout(() => done(null), 3000);
839
+ this.#inspectWait.set(id, done);
840
+ ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
841
+ });
842
+ }
843
+
844
+ // Latest pointer position wins; at most one hover query in flight.
845
+ #probing = false;
846
+ #probeAgain = false;
847
+ async #probeHover() {
848
+ if (this.#probing) { this.#probeAgain = true; return; }
849
+ this.#probing = true;
850
+ try {
851
+ do {
852
+ this.#probeAgain = false;
853
+ const p = this.#hoverPt;
854
+ if (!p || !this.#annot) break;
855
+ const r = await this.#inspect(p);
856
+ if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
857
+ } while (this.#probeAgain);
858
+ } finally {
859
+ this.#probing = false;
860
+ }
861
+ }
862
+
863
+ // The page changed (scroll, layout, HMR): follow the selected element and
864
+ // re-check what is under the pointer.
865
+ #tracking = false;
866
+ async #annotFrame() {
867
+ if (this.#hoverPt) this.#probeHover();
868
+ const sel = this.#sel;
869
+ if (!sel || this.#tracking) { this.#drawHl(); return; }
870
+ this.#tracking = true;
871
+ try {
872
+ const r = await this.#inspect({ selector: sel.selector });
873
+ if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
874
+ } finally {
875
+ this.#tracking = false;
876
+ }
877
+ this.#drawHl();
878
+ }
879
+
880
+ #select(t) {
881
+ const { note, noteBox, noteHint, noteTarget } = this.$;
882
+ this.#sel = t;
883
+ note.hidden = !t;
884
+ noteHint.textContent = "";
885
+ if (t) {
886
+ noteTarget.textContent = t.selector;
887
+ noteTarget.title = t.selector;
888
+ noteBox.focus();
889
+ }
890
+ this.#drawHl();
891
+ }
892
+
893
+ // Frame (CSS px of the page viewport) -> position over the displayed image.
894
+ #drawHl() {
895
+ const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
896
+ const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
897
+ const vr = viewport.getBoundingClientRect();
898
+ const ir = img.getBoundingClientRect();
899
+ const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
900
+ const box = (r) => {
901
+ if (!show || !r) return null;
902
+ const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
903
+ const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
904
+ if (x1 <= x0 || y1 <= y0) return null;
905
+ return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
906
+ };
907
+ const place = (el, b) => {
908
+ el.classList.toggle("on", !!b);
909
+ if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
910
+ return b;
911
+ };
912
+ const hb = place(hlHover, box(this.#hover?.rect));
913
+ const sb = place(hlSel, box(this.#sel?.rect));
914
+
915
+ hlLabel.classList.toggle("on", !!hb);
916
+ if (hb) {
917
+ const r = this.#hover.rect;
918
+ hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
919
+ // above the box, else below it, else inside its top edge
920
+ const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
921
+ Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
922
+ }
923
+
924
+ // input below the selected element, above it if there is no room
925
+ if (!note.hidden) {
926
+ const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
927
+ const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
928
+ let top = b.top + b.height + pad;
929
+ if (top + H > vr.height - pad) top = b.top - H - pad;
930
+ top = Math.max(pad, Math.min(top, vr.height - H - pad));
931
+ const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
932
+ Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
933
+ }
934
+ }
935
+
936
+ #bindAnnotate() {
937
+ const { img, annotBtn, note, noteBox, noteHint } = this.$;
938
+ annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
939
+ img.addEventListener("mousemove", (e) => {
940
+ if (!this.#annot || !this.#frame.w) return;
941
+ this.#hoverPt = this.#point(e);
942
+ this.#probeHover();
943
+ });
944
+ img.addEventListener("mouseleave", () => {
945
+ if (!this.#annot) return;
946
+ this.#hoverPt = null;
947
+ this.#hover = null;
948
+ this.#drawHl();
949
+ });
950
+ img.addEventListener("click", async (e) => {
951
+ if (!this.#annot || !this.#frame.w) return;
952
+ e.preventDefault();
953
+ const t = await this.#inspect(this.#point(e), true);
954
+ if (t && this.#annot) this.#select(t);
955
+ });
956
+ new ResizeObserver(() => this.#drawHl()).observe(img);
957
+ note.addEventListener("submit", async (e) => {
958
+ e.preventDefault();
959
+ const text = noteBox.value.trim();
960
+ const target = this.#sel;
961
+ if (!text || !target) return;
962
+ noteHint.textContent = "보내는 중…";
963
+ const res = await this.send(text, "annotate", { target });
964
+ if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
965
+ noteBox.value = "";
966
+ if (this.#sel === target) this.#select(null);
967
+ });
968
+ noteBox.addEventListener("keydown", (e) => {
969
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
970
+ if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
971
+ });
972
+ }
973
+
756
974
  #renderScreenMeta(m) {
757
975
  const { live, url, viewport } = this.$;
758
976
  if (!m.connected) {
@@ -768,6 +986,7 @@ class AgentBridge extends HTMLElement {
768
986
  url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
769
987
  url.title = m.url || "";
770
988
  }
989
+ this.#drawHl();
771
990
  this.#emit("agent-screen", m);
772
991
  }
773
992