agello 0.1.4 → 0.2.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
@@ -67,11 +67,26 @@ The approve / reject buttons send `action=approve` / `action=reject`.
67
67
  | `agello stop [--port N \| --pane ID \| --all]` | Stop a server (default: this pane's) |
68
68
  | `agello status [--json]` | List running servers, agent state and screen browser |
69
69
  | `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
70
+ | `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
71
+ | `agello present stop` | End presentation mode |
70
72
 
71
73
  `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
72
74
 
73
75
  State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
74
76
 
77
+ ### Presentation mode
78
+
79
+ `agello present x,y,w,h` (CSS px of the browser's visible viewport; omit for the whole screen):
80
+
81
+ - only that rectangle is sent to the page (`Page.captureScreenshot` with a clip, at device resolution)
82
+ - the screen panel expands to fill the window
83
+ - agent replies appear over it as bubbles that fade after 10s
84
+ - `agello present stop` returns to the normal layout; the replies are in the chat as usual
85
+ - the viewer can ask a question with the raise-hand button (its bubble fades after 10s too) or end the presentation with the end button; the agent then receives `[browser] action=present-stop`
86
+ - raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing the input without asking sends `action=hand-lower`
87
+
88
+ Run `present` again to move the crop. User control is off while presenting.
89
+
75
90
  ## Embed
76
91
 
77
92
  ```html
package/bin/agello.ts CHANGED
@@ -22,6 +22,11 @@ Usage:
22
22
  ${NAME} stop [--port N|--pane ID|--all] Stop a server (default: this pane's)
23
23
  ${NAME} status [--json] List running servers
24
24
  ${NAME} open [--port N|--pane ID] Open the page (default: this pane's server)
25
+ ${NAME} present [x,y,w,h] [--port N|--pane ID] Presentation mode: the page shows only the screen,
26
+ full size, cropped to the rect (CSS px of the visible viewport; omit for the
27
+ whole screen), with agent replies as bubbles that fade after 10s.
28
+ Run again to move the crop.
29
+ ${NAME} present stop [--port N|--pane ID] End presentation mode (bubbles stay in the chat)
25
30
  ${NAME} help | --version
26
31
 
27
32
  start options:
@@ -279,6 +284,40 @@ async function cmdOpen(argv: string[]) {
279
284
  console.log(inst.url);
280
285
  }
281
286
 
287
+ async function cmdPresent(argv: string[]) {
288
+ const { values: o, positionals } = parseArgs({
289
+ args: argv,
290
+ options: { port: { type: "string" }, pane: { type: "string" } },
291
+ allowPositionals: true,
292
+ });
293
+ const inst = await resolveTarget(o);
294
+ const arg = positionals.join(",");
295
+ let body: object;
296
+ if (arg === "stop") body = { stop: true };
297
+ else if (!arg) body = {};
298
+ else {
299
+ const v = arg.split(/[\s,]+/).filter(Boolean).map(Number);
300
+ if (v.length !== 4 || !v.every(Number.isFinite)) fail(`rect must be x,y,w,h (got "${arg}")`);
301
+ body = { rect: { x: v[0], y: v[1], w: v[2], h: v[3] } };
302
+ }
303
+ let res: any;
304
+ try {
305
+ const r = await fetch(`${inst.url}/present`, {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify(body),
309
+ signal: AbortSignal.timeout(4000),
310
+ });
311
+ res = await r.json();
312
+ } catch {
313
+ fail(`server unreachable: ${inst.url}`);
314
+ }
315
+ if (!res?.ok) fail(res?.error ?? "failed");
316
+ const p = res.present;
317
+ if (!p.on) console.log("presentation: off");
318
+ else console.log(`presentation: on ${p.rect ? `rect=${p.rect.x},${p.rect.y},${p.rect.w},${p.rect.h}` : "(whole screen)"}`);
319
+ }
320
+
282
321
  // ---------- main ----------
283
322
 
284
323
  const [cmd, ...rest] = Bun.argv.slice(2);
@@ -295,6 +334,9 @@ switch (cmd) {
295
334
  case "open":
296
335
  await cmdOpen(rest);
297
336
  break;
337
+ case "present":
338
+ await cmdPresent(rest);
339
+ break;
298
340
  case "--version":
299
341
  case "-v":
300
342
  console.log(pkg.version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.1.4",
3
+ "version": "0.2.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
@@ -14,6 +14,9 @@ type Browser = {
14
14
  tabs: { id: number; url: string; title: string; active: boolean; targetId: string; agentControlled: boolean }[];
15
15
  };
16
16
 
17
+ // Presentation crop: a rectangle of the visible viewport, in CSS pixels.
18
+ export type Rect = { x: number; y: number; w: number; h: number };
19
+
17
20
  export type ScreenMeta = {
18
21
  connected: boolean;
19
22
  reason?: "browser_not_found" | "no_browser_in_tab" | "tab_unknown" | "no_active_tab";
@@ -28,11 +31,15 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
28
31
  const enc = new TextEncoder();
29
32
 
30
33
  let meta: ScreenMeta = { connected: false };
31
- let lastFrame: { data: string; w: number; h: number } | null = null;
34
+ type Frame = { data: string; w: number; h: number; clip?: Rect };
35
+ let lastFrame: Frame | null = null; // full screencast frame
36
+ let lastCrop: Frame | null = null; // cropped frame while a clip is set
37
+ let clip: Rect | null = null;
32
38
  let ws: WebSocket | null = null;
33
39
  let wsTarget = "";
34
40
  let msgId = 0;
35
41
  let timer: Timer | null = null;
42
+ const pending = new Map<number, (result: any) => void>();
36
43
 
37
44
  const emit = (c: ReadableStreamDefaultController, event: string, data: unknown) => {
38
45
  try {
@@ -74,6 +81,63 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
74
81
  ws?.close();
75
82
  ws = null;
76
83
  wsTarget = "";
84
+ pending.forEach((r) => r(null));
85
+ pending.clear();
86
+ }
87
+
88
+ // CDP call that resolves with the result (null on error or closed socket).
89
+ function request(method: string, params: object = {}): Promise<any> {
90
+ const sock = ws;
91
+ if (!sock || sock.readyState !== WebSocket.OPEN) return Promise.resolve(null);
92
+ return new Promise((resolve) => {
93
+ const id = ++msgId;
94
+ const t = setTimeout(() => pending.delete(id) && resolve(null), 5000);
95
+ pending.set(id, (r) => (clearTimeout(t), resolve(r)));
96
+ sock.send(JSON.stringify({ id, method, params }));
97
+ });
98
+ }
99
+
100
+ // While a clip is set, only the clipped region is sent to viewers: every
101
+ // screencast frame (= the page changed) triggers one Page.captureScreenshot
102
+ // of the rectangle at full device resolution. At most one capture runs at a
103
+ // time; changes during a capture are coalesced into one more capture.
104
+ let capturing = false;
105
+ let dirty = false;
106
+ async function capture() {
107
+ if (!clip) return;
108
+ if (capturing) {
109
+ dirty = true;
110
+ return;
111
+ }
112
+ capturing = true;
113
+ try {
114
+ do {
115
+ dirty = false;
116
+ const c = clip;
117
+ if (!c) break;
118
+ // clip coordinates are document-relative; the rect is viewport-relative
119
+ const vv = (await request("Page.getLayoutMetrics"))?.cssVisualViewport;
120
+ if (!vv) break;
121
+ const shot = await request("Page.captureScreenshot", {
122
+ format: "jpeg",
123
+ quality: 85,
124
+ clip: { x: c.x + vv.pageX, y: c.y + vv.pageY, width: c.w, height: c.h, scale: 1 },
125
+ });
126
+ if (!shot?.data || clip !== c) continue;
127
+ lastCrop = { data: shot.data, w: c.w, h: c.h, clip: c };
128
+ broadcast("frame", lastCrop);
129
+ await Bun.sleep(100); // cap at ~10 captures/s
130
+ } while (dirty && clip);
131
+ } finally {
132
+ capturing = false;
133
+ }
134
+ }
135
+
136
+ function setClip(next: Rect | null) {
137
+ clip = next;
138
+ lastCrop = null;
139
+ if (clip) capture();
140
+ else if (lastFrame) broadcast("frame", lastFrame);
77
141
  }
78
142
 
79
143
  function connect(port: number, targetId: string) {
@@ -88,14 +152,22 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
88
152
  sock.onopen = () => {
89
153
  call("Page.enable");
90
154
  call("Page.startScreencast", { format: "jpeg", quality: 70, maxWidth: 1600, maxHeight: 1600 });
155
+ if (clip) capture();
91
156
  };
92
157
  sock.onmessage = (ev) => {
93
158
  const msg = JSON.parse(String(ev.data));
159
+ if (msg.id && pending.has(msg.id)) {
160
+ const resolve = pending.get(msg.id)!;
161
+ pending.delete(msg.id);
162
+ resolve(msg.result ?? null);
163
+ return;
164
+ }
94
165
  if (msg.method !== "Page.screencastFrame") return;
95
166
  const { data, metadata, sessionId } = msg.params;
96
167
  call("Page.screencastFrameAck", { sessionId });
97
168
  lastFrame = { data, w: metadata.deviceWidth, h: metadata.deviceHeight };
98
- broadcast("frame", lastFrame);
169
+ if (clip) capture();
170
+ else broadcast("frame", lastFrame);
99
171
  };
100
172
  sock.onclose = () => {
101
173
  if (ws === sock) {
@@ -122,6 +194,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
122
194
  if (!b || !tab) {
123
195
  disconnect();
124
196
  lastFrame = null;
197
+ lastCrop = null;
125
198
  setMeta({ connected: false, reason: reason ?? "no_active_tab" });
126
199
  return;
127
200
  }
@@ -146,6 +219,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
146
219
  timer = null;
147
220
  disconnect();
148
221
  lastFrame = null;
222
+ lastCrop = null;
149
223
  meta = { connected: false };
150
224
  }
151
225
 
@@ -158,7 +232,8 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
158
232
  ctrl = c;
159
233
  viewers.add(c);
160
234
  emit(c, "meta", meta);
161
- if (lastFrame) emit(c, "frame", lastFrame);
235
+ const f = clip ? lastCrop : lastFrame;
236
+ if (f) emit(c, "frame", f);
162
237
  ping = setInterval(() => emit(c, "ping", {}), 15000);
163
238
  start();
164
239
  },
@@ -181,7 +256,8 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
181
256
  } catch {
182
257
  return;
183
258
  }
184
- if (!INPUT_METHODS.has(m?.method) || !ws || ws.readyState !== WebSocket.OPEN) return;
259
+ // input coordinates assume the full frame: ignore input while cropped
260
+ if (clip || !INPUT_METHODS.has(m?.method) || !ws || ws.readyState !== WebSocket.OPEN) return;
185
261
  ws.send(JSON.stringify({ id: ++msgId, method: m.method, params: m.params ?? {} }));
186
262
  }
187
263
 
@@ -192,5 +268,5 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
192
268
  return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
193
269
  }
194
270
 
195
- return { handle, input, describe };
271
+ return { handle, input, describe, setClip };
196
272
  }
package/src/server.ts CHANGED
@@ -7,11 +7,15 @@
7
7
  // POST /send {action, text} -> herdr agent prompt
8
8
  // GET /screen SSE: terminal-browser screen frames (CDP screencast)
9
9
  // WS /input user control: CDP Input.* commands forwarded to the page
10
+ // GET /present presentation state
11
+ // POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
12
+ // ({stop: true, from: "viewer"}: ended from the page, the agent is told
13
+ // with "[browser] action=present-stop")
10
14
 
11
15
  import { homedir } from "node:os";
12
16
  import { join } from "node:path";
13
17
  import { runJson } from "./run.ts";
14
- import { createScreencast } from "./screencast.ts";
18
+ import { createScreencast, type Rect } from "./screencast.ts";
15
19
 
16
20
  export type ServerOptions = {
17
21
  port: number;
@@ -77,6 +81,17 @@ async function herdr(timeout: number, ...cmd: string[]): Promise<any> {
77
81
  return (await runJson(["herdr", ...cmd], timeout)) ?? { error: { code: "herdr_failed" } };
78
82
  }
79
83
 
84
+ // Presentation rect from the API: finite, non-negative origin, positive size (CSS px).
85
+ export function parseRect(r: any): Rect | null {
86
+ if (!r || typeof r !== "object") return null;
87
+ const v = [r.x, r.y, r.w, r.h].map(Number);
88
+ if (!v.every(Number.isFinite) || v[0] < 0 || v[1] < 0 || v[2] < 1 || v[3] < 1) return null;
89
+ const [x, y, w, h] = v.map(Math.round);
90
+ return { x, y, w, h };
91
+ }
92
+
93
+ export type Present = { on: boolean; rect?: Rect; since?: string };
94
+
80
95
  type Status =
81
96
  | { alive: false; reason: string; pane: string }
82
97
  | { alive: true; status: string; pane: string; label?: string; session: string; title?: string };
@@ -291,6 +306,40 @@ export async function startServer(opts: ServerOptions) {
291
306
  herdrTab: async () => (await agentInfo())?.tab_id,
292
307
  });
293
308
 
309
+ // ----- presentation mode -----
310
+ // on: viewers show only the screen, full size, with agent replies as
311
+ // short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
312
+
313
+ let present: Present = { on: false };
314
+ function setPresent(next: Present) {
315
+ present = next;
316
+ screen.setClip(next.on ? (next.rect ?? null) : null);
317
+ broadcast("present", present);
318
+ }
319
+
320
+ async function presentRequest(req: Request): Promise<Response> {
321
+ const data = (await req.json().catch(() => ({}))) as { rect?: unknown; stop?: boolean; from?: string };
322
+ if (data.stop) {
323
+ const was = present.on;
324
+ setPresent({ on: false });
325
+ // Ended from the page: the agent may still be presenting, so tell it.
326
+ // (The CLI stop comes from the agent itself and needs no notice.)
327
+ let notified: string | undefined;
328
+ if (was && data.from === "viewer") {
329
+ const r = await promptAgent("present-stop", "사용자가 페이지에서 발표를 종료함");
330
+ notified = r.ok ? "ok" : r.error;
331
+ }
332
+ return Response.json({ ok: true, present, notified });
333
+ }
334
+ let rect: Rect | undefined;
335
+ if (data.rect != null) {
336
+ rect = parseRect(data.rect) ?? undefined;
337
+ if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
338
+ }
339
+ setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
340
+ return Response.json({ ok: true, present });
341
+ }
342
+
294
343
  // ----- HTTP -----
295
344
 
296
345
  // Same-origin and localhost pages are allowed by default; add more with
@@ -329,6 +378,7 @@ export async function startServer(opts: ServerOptions) {
329
378
  tools: [...pendingTools.keys()],
330
379
  });
331
380
  if (lastStatus) send(c, "status", lastStatus);
381
+ send(c, "present", present);
332
382
  for (const t of pendingTools.values()) send(c, "tool_start", t);
333
383
  ping = setInterval(() => send(c, "ping", {}), 15000);
334
384
  },
@@ -342,22 +392,32 @@ export async function startServer(opts: ServerOptions) {
342
392
  });
343
393
  }
344
394
 
345
- async function sendPrompt(req: Request): Promise<Response> {
346
- const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
395
+ // Type a [browser] prompt into the agent session.
396
+ async function promptAgent(action: string, text: string): Promise<{ ok: boolean; error?: string; status?: number }> {
347
397
  const st = await checkStatus();
348
- if (!st.alive) return Response.json({ ok: false, error: st.reason }, { status: 409 });
349
- if (st.status === "blocked") return Response.json({ ok: false, error: "agent_blocked" }, { status: 409 });
398
+ if (!st.alive) return { ok: false, error: st.reason, status: 409 };
399
+ if (st.status === "blocked") return { ok: false, error: "agent_blocked", status: 409 };
400
+ const res = await herdr(10000, "agent", "prompt", PANE, formatPrompt(action, text.trim()));
401
+ if (res?.error) return { ok: false, error: res.error.code, status: 502 };
402
+ return { ok: true };
403
+ }
350
404
 
405
+ async function sendPrompt(req: Request): Promise<Response> {
406
+ const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
351
407
  const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
352
- const res = await herdr(10000, "agent", "prompt", PANE, formatPrompt(action, (data.text ?? "").trim()));
353
- if (res?.error) return Response.json({ ok: false, error: res.error.code }, { status: 502 });
354
- return Response.json({ ok: true });
408
+ const r = await promptAgent(action, data.text ?? "");
409
+ return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
355
410
  }
356
411
 
357
412
  async function route(req: Request, url: URL): Promise<Response> {
358
413
  switch (url.pathname) {
359
414
  case "/status":
360
- return Response.json({ ...(await checkStatus()), browser: opts.browser, screen: await screen.describe() });
415
+ return Response.json({
416
+ ...(await checkStatus()),
417
+ browser: opts.browser,
418
+ screen: await screen.describe(),
419
+ present,
420
+ });
361
421
  case "/events":
362
422
  return events();
363
423
  case "/screen":
@@ -368,6 +428,9 @@ export async function startServer(opts: ServerOptions) {
368
428
  });
369
429
  case "/send":
370
430
  if (req.method === "POST") return sendPrompt(req);
431
+ break;
432
+ case "/present":
433
+ return req.method === "POST" ? presentRequest(req) : Response.json(present);
371
434
  }
372
435
  return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
373
436
  }
@@ -23,10 +23,24 @@
23
23
  // agent-tool detail: { phase: 'start'|'end', id, name?, summary?, isError? }
24
24
  // (tool calls are also kept in the conversation as one-line rows, like the terminal)
25
25
  // agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
26
+ // agent-present detail: { on, rect?: {x,y,w,h}, since? }
27
+ //
28
+ // Presentation mode (started by the agent: `agello present [x,y,w,h]`)
29
+ // The screen panel expands to fill the window and shows only the given
30
+ // rectangle of the browser. Agent replies appear over it as bubbles that fade
31
+ // after 10s; they are also added to the chat, so it is all there when the
32
+ // presentation ends. User control is off while presenting.
33
+ // A raise-hand button next to the bubbles opens a small input so the viewer
34
+ // can ask the agent something mid-presentation (sent as action=message).
35
+ // Opening it tells the agent right away (action=hand-raise) so it can pause;
36
+ // closing it without asking sends action=hand-lower so it can go on.
37
+ // The viewer's questions show as bubbles too, with the same 10s lifetime.
38
+ // An end button above it stops the presentation for every viewer.
26
39
  //
27
40
  // Methods
28
41
  // el.send(text, action = 'message') -> Promise<{ok, error?}>
29
42
  // el.clearHistory() clear saved chat for the current pane
43
+ // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
30
44
 
31
45
  const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
32
46
 
@@ -53,7 +67,10 @@ const SCREEN_REASONS = {
53
67
  tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
54
68
  no_active_tab: "브라우저에 활성 탭 없음",
55
69
  };
56
- const ACTIONS = { message: "메시지", approve: "승인", reject: "거절" };
70
+ const ACTIONS = {
71
+ message: "메시지", approve: "승인", reject: "거절",
72
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
73
+ };
57
74
 
58
75
  const CSS = `
59
76
  :host {
@@ -154,6 +171,60 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
154
171
  .control-btn:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; border-radius: 6px; }
155
172
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
156
173
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
174
+
175
+ /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
176
+ container-type would make the host the containing block of the fixed layer, so drop it. */
177
+ :host([presenting]) { container-type: normal; }
178
+ .body.present .screen { display: flex; position: fixed; inset: 0; z-index: 2147483000; border: 0;
179
+ width: auto; height: auto; background: #0b0b0c; }
180
+ .body.present .screen-bar, .body.present .ring, .body.present .glow, .body.present .agent-badge { display: none; }
181
+ .body.present .viewport { padding: 0; background: #0b0b0c; }
182
+ .body.present .viewport img { width: 100%; height: 100%; max-width: none; max-height: none; border-radius: 0;
183
+ box-shadow: none; }
184
+ .captions { display: none; }
185
+ .body.present .captions { position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; display: flex;
186
+ flex-direction: column; align-items: center; gap: 10px; padding: 0 88px 28px; pointer-events: none; }
187
+ .caption { max-width: min(900px, 86%); padding: 12px 18px; border-radius: 16px; overflow-wrap: anywhere;
188
+ font-size: 18px; line-height: 1.5; color: #fff; background: rgba(20, 20, 22, .72);
189
+ -webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
190
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
191
+ .caption.plain { white-space: pre-wrap; }
192
+ .caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
193
+ .caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
194
+ .caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
195
+ .caption p, .caption ul, .caption ol { margin: .35em 0; }
196
+ .caption code { font-family: ui-monospace, Menlo, monospace; font-size: .85em; background: rgba(255,255,255,.14);
197
+ padding: 1px 5px; border-radius: 4px; }
198
+ .caption pre { background: rgba(255,255,255,.1); padding: 8px 10px; border-radius: 8px; overflow-x: auto; }
199
+ .caption a { color: inherit; }
200
+ @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
201
+
202
+ /* raise hand: ask the agent mid-presentation */
203
+ .hand-wrap { display: none; }
204
+ .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
205
+ flex-direction: column; align-items: flex-end; gap: 10px; }
206
+ .hand, .end { width: 48px; height: 48px; padding: 0; border-radius: 50%; display: grid; place-items: center;
207
+ color: #fff; border: 1px solid rgba(255,255,255,.18); background: rgba(20, 20, 22, .72);
208
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
209
+ transition: background .2s ease, transform .2s ease; }
210
+ .hand:hover, .end:hover { background: rgba(40, 40, 44, .85); transform: translateY(-2px); }
211
+ .hand[aria-expanded="true"] { background: var(--_accent); border-color: var(--_accent); }
212
+ .hand.sent { background: var(--_ok); border-color: var(--_ok); }
213
+ .end:hover { background: var(--_dead); border-color: var(--_dead); }
214
+ .end:disabled { opacity: .5; cursor: progress; }
215
+ .hand:focus-visible, .end:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
216
+ .hand svg, .end svg { width: 22px; height: 22px; }
217
+ .ask { width: min(420px, calc(100vw - 44px)); padding: 12px; border-radius: 16px; color: #fff;
218
+ background: rgba(20, 20, 22, .82); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
219
+ box-shadow: 0 8px 30px rgba(0,0,0,.4); animation: capIn .2s ease; }
220
+ .ask[hidden] { display: none; }
221
+ .ask textarea { min-height: 64px; background: rgba(255,255,255,.08); color: #fff;
222
+ border-color: rgba(255,255,255,.16); font-size: 15px; }
223
+ .ask textarea::placeholder { color: rgba(255,255,255,.5); }
224
+ .ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
225
+ .ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
226
+ @media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
227
+ @media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
157
228
  @container (max-width: 820px) {
158
229
  .body { flex-direction: column; }
159
230
  :host([screen]) .screen { order: -1; flex: none; height: 45%; border-left: 0; border-bottom: 1px solid var(--_line); }
@@ -256,7 +327,17 @@ const TEMPLATE = `
256
327
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
257
328
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
258
329
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
259
- <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>
330
+ <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>
331
+ <div class="hand-wrap">
332
+ <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
333
+ <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
334
+ <button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
335
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
336
+ </button>
337
+ <button class="hand" part="hand" type="button" aria-expanded="false" aria-label="손들기: 에이전트에게 질문" title="손들기: 에이전트에게 질문">
338
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
339
+ </button>
340
+ </div></div>
260
341
  </aside></div>`;
261
342
 
262
343
  class AgentBridge extends HTMLElement {
@@ -273,7 +354,10 @@ class AgentBridge extends HTMLElement {
273
354
  #md = null;
274
355
  #historyKey = null;
275
356
  #history = [];
357
+ #present = { on: false };
276
358
  static HISTORY_LIMIT = 500;
359
+ static CAPTION_MS = 10000; // presentation bubble lifetime
360
+ static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
277
361
 
278
362
  constructor() {
279
363
  super();
@@ -285,6 +369,8 @@ class AgentBridge extends HTMLElement {
285
369
  buttons: this.#root.querySelectorAll("button[data-action]"),
286
370
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
287
371
  controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
372
+ body: q(".body"), screen: q(".screen"), captions: q(".captions"),
373
+ hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
288
374
  };
289
375
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
290
376
  this.#bindGrip();
@@ -296,6 +382,7 @@ class AgentBridge extends HTMLElement {
296
382
  });
297
383
  loadMarkdown().then((md) => (this.#md = md));
298
384
  this.#bindControl();
385
+ this.#bindHand();
299
386
  this.$.clearBtn.addEventListener("click", () => this.clearHistory());
300
387
  }
301
388
 
@@ -334,6 +421,19 @@ class AgentBridge extends HTMLElement {
334
421
  }
335
422
  }
336
423
 
424
+ async stopPresent() {
425
+ try {
426
+ const res = await fetch(`${this.server}/present`, {
427
+ method: "POST",
428
+ headers: { "Content-Type": "application/json" },
429
+ body: JSON.stringify({ stop: true, from: "viewer" }),
430
+ });
431
+ return await res.json();
432
+ } catch {
433
+ return { ok: false, error: "server_unreachable" };
434
+ }
435
+ }
436
+
337
437
  #emit(type, detail) {
338
438
  this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
339
439
  }
@@ -349,7 +449,14 @@ class AgentBridge extends HTMLElement {
349
449
  on("delivered", ({ id }) => { this.#deliver(id); this.#emit("agent-queue", { phase: "delivered", id }); });
350
450
  on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
351
451
  on("status", (s) => this.#renderStatus(s));
352
- on("message", (m) => { this.#addMessage(m); this.#saveMessage(m); this.#emit("agent-message", m); });
452
+ on("message", (m) => {
453
+ this.#addMessage(m);
454
+ this.#saveMessage(m);
455
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text);
456
+ if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
457
+ this.#emit("agent-message", m);
458
+ });
459
+ on("present", (p) => this.#setPresent(p));
353
460
  on("tool_start", (t) => {
354
461
  this.#tools.set(t.id, t);
355
462
  this.#renderActivity();
@@ -366,11 +473,11 @@ class AgentBridge extends HTMLElement {
366
473
  this.#setToolState(t.id, t.isError ? "error" : "done");
367
474
  this.#emit("agent-tool", { phase: "end", ...t });
368
475
  });
369
- es.onerror = () => this.#renderStatus(null);
476
+ es.onerror = () => { this.#renderStatus(null); this.#setPresent({ on: false }); };
370
477
  }
371
478
 
372
479
  #syncScreen() {
373
- const want = this.hasAttribute("screen");
480
+ const want = this.hasAttribute("screen") || this.#present.on;
374
481
  if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
375
482
  if (this.#screenEs) return;
376
483
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
@@ -384,6 +491,110 @@ class AgentBridge extends HTMLElement {
384
491
  es.onerror = () => this.#renderScreenMeta({ connected: false });
385
492
  }
386
493
 
494
+ // ---------- presentation mode ----------
495
+
496
+ #setPresent(p) {
497
+ const on = !!p?.on;
498
+ const was = this.#present.on;
499
+ this.#present = p ?? { on: false };
500
+ this.#emit("agent-present", this.#present);
501
+ if (on === was) return;
502
+ if (on) this.#setControl(false);
503
+ else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
504
+ this.#syncScreen();
505
+ // FLIP: grow from (or shrink back to) the side panel
506
+ const { screen, body } = this.$;
507
+ const before = screen.getBoundingClientRect();
508
+ body.classList.toggle("present", on);
509
+ this.toggleAttribute("presenting", on);
510
+ const after = screen.getBoundingClientRect();
511
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches || !before.width || !after.width) return;
512
+ const t = `translate(${before.left - after.left}px, ${before.top - after.top}px) ` +
513
+ `scale(${before.width / after.width}, ${before.height / after.height})`;
514
+ screen.animate([{ transformOrigin: "0 0", transform: t }, { transformOrigin: "0 0", transform: "none" }],
515
+ { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
516
+ }
517
+
518
+ // Raise hand: open a small input over the presentation and send it as a message.
519
+ #bindHand() {
520
+ const { hand, ask, askBox, askHint } = this.$;
521
+ hand.addEventListener("click", () => {
522
+ if (ask.hidden) this.#raiseHand();
523
+ else this.#lowerHand();
524
+ });
525
+ this.$.end.addEventListener("click", async () => {
526
+ this.$.end.disabled = true;
527
+ const res = await this.stopPresent();
528
+ this.$.end.disabled = false;
529
+ if (!res.ok) { this.#openAsk(true); askHint.textContent = `발표 종료 실패: ${res.error}`; }
530
+ });
531
+ ask.addEventListener("submit", async (e) => {
532
+ e.preventDefault();
533
+ const text = askBox.value.trim();
534
+ if (!text) return;
535
+ askHint.textContent = "보내는 중…";
536
+ const res = await this.send(text, "message");
537
+ if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
538
+ askBox.value = "";
539
+ this.#handUp = false; // the question itself ends the raised hand
540
+ this.#openAsk(false);
541
+ hand.classList.add("sent");
542
+ setTimeout(() => hand.classList.remove("sent"), 1500);
543
+ });
544
+ askBox.addEventListener("keydown", (e) => {
545
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
546
+ if (e.key === "Escape") { e.preventDefault(); this.#lowerHand(); hand.focus(); }
547
+ });
548
+ }
549
+
550
+ // Raise: open the input and tell the agent to pause. Lower (closed without
551
+ // asking): tell it to go on. Failures only show a hint; asking still works.
552
+ #handUp = false;
553
+ async #raiseHand() {
554
+ this.#openAsk(true);
555
+ if (this.#handUp) return;
556
+ this.#handUp = true;
557
+ const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
558
+ if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
559
+ }
560
+ async #lowerHand() {
561
+ this.#openAsk(false);
562
+ if (!this.#handUp) return;
563
+ this.#handUp = false;
564
+ await this.send("사용자가 질문 없이 손을 내림", "hand-lower");
565
+ }
566
+
567
+ #openAsk(open) {
568
+ const { hand, ask, askBox, askHint } = this.$;
569
+ ask.hidden = !open;
570
+ hand.setAttribute("aria-expanded", String(open));
571
+ askHint.textContent = "";
572
+ if (open) askBox.focus();
573
+ }
574
+
575
+ // Agent reply as a short-lived bubble over the presented screen.
576
+ // kind "user": the viewer's own question (plain text, accent colour).
577
+ #caption(text, kind = "agent") {
578
+ if (!text?.trim()) return;
579
+ const el = document.createElement("div");
580
+ el.className = `caption ${kind}`;
581
+ el.part = "caption";
582
+ if (kind === "user") { el.textContent = text; el.classList.add("plain"); }
583
+ else if (this.#md) el.innerHTML = this.#md(text);
584
+ else { el.textContent = text; el.classList.add("plain"); }
585
+ const box = this.$.captions;
586
+ box.append(el);
587
+ const leave = () => {
588
+ if (el.classList.contains("out")) return;
589
+ el.classList.add("out");
590
+ setTimeout(() => el.remove(), 500);
591
+ };
592
+ setTimeout(leave, AgentBridge.CAPTION_MS);
593
+ const live = [...box.children].filter((c) => !c.classList.contains("out"));
594
+ live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
595
+ el.__leave = leave;
596
+ }
597
+
387
598
  // ---------- composer resize grip (top edge) ----------
388
599
 
389
600
  #bindGrip() {
@@ -456,7 +667,7 @@ class AgentBridge extends HTMLElement {
456
667
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
457
668
  const BUTTONS = ["left", "middle", "right"];
458
669
 
459
- controlBtn.addEventListener("click", () => this.#setControl(!this.#controlWs));
670
+ controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
460
671
 
461
672
  img.addEventListener("mousedown", (e) => {
462
673
  if (!on()) return;