agello 0.1.4 → 0.2.1

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,45 @@ 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 |
72
+ | `agello script load <file> \| show` | Load / show a presentation script |
73
+ | `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
70
74
 
71
75
  `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
72
76
 
73
77
  State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
74
78
 
79
+ ### Presentation mode
80
+
81
+ `agello present x,y,w,h` (CSS px of the browser's visible viewport; omit for the whole screen):
82
+
83
+ - only that rectangle is sent to the page (`Page.captureScreenshot` with a clip, at device resolution)
84
+ - the screen panel expands to fill the window
85
+ - agent replies appear over it as bubbles that fade after 10s
86
+ - `agello present stop` returns to the normal layout; the replies are in the chat as usual
87
+ - 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`
88
+ - raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing the input without asking sends `action=hand-lower`
89
+
90
+ Run `present` again to move the crop. User control is off while presenting.
91
+
92
+ #### Scripts
93
+
94
+ Write the talk ahead so each line appears the moment its screen does:
95
+
96
+ ```json
97
+ { "rect": "28,157,688,477",
98
+ "steps": [
99
+ { "go": "#1", "say": ["First line", "Second line"] },
100
+ { "go": "#2", "say": ["..."], "hold": 6 } ] }
101
+ ```
102
+
103
+ - step: `go` brings the screen there (`#n` sets `location.hash`, anything else is JS run in the page; agello knows nothing about the deck), `say` lines are one bubble each, `hold` seconds a line stays on screen (default from length, 10 to 20s; never below 10s). Pacing: screen change, 1s, line, fade out, 0.3s, next line; after a step's last line 0.7s before the next screen
104
+ - `present resume` plays from where it stopped (starts presentation mode with the script's `rect`); it first re-runs the current step's `go`, so the agent may move the screen freely while answering
105
+ - raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
106
+ - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
107
+ - at the end the agent gets `action=present-done`; while the script is paused and the agent is working, the page shows a small loader
108
+
75
109
  ## Embed
76
110
 
77
111
  ```html
package/bin/agello.ts CHANGED
@@ -22,6 +22,18 @@ 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)
30
+ ${NAME} script load <file.json> Load a presentation script (keeps the current position):
31
+ {"rect": "x,y,w,h", "steps": [{"go": "#1", "say": ["line", ...], "hold": 6}]}
32
+ go: "#n" sets location.hash, otherwise JS run in the page; one line = one bubble
33
+ ${NAME} script show Script with positions (step.line) and player state
34
+ ${NAME} present resume Play the script from where it stopped (starts presentation mode)
35
+ ${NAME} present pause Stop the script (raising a hand on the page also pauses)
36
+ ${NAME} present goto <n[.m]> Move to step n (line m) and show its screen
25
37
  ${NAME} help | --version
26
38
 
27
39
  start options:
@@ -279,6 +291,98 @@ async function cmdOpen(argv: string[]) {
279
291
  console.log(inst.url);
280
292
  }
281
293
 
294
+ async function api(url: string, path: string, body?: object): Promise<any> {
295
+ try {
296
+ const r = await fetch(`${url}${path}`, {
297
+ method: body ? "POST" : "GET",
298
+ headers: { "Content-Type": "application/json" },
299
+ body: body ? JSON.stringify(body) : undefined,
300
+ signal: AbortSignal.timeout(8000),
301
+ });
302
+ return await r.json();
303
+ } catch {
304
+ return fail(`server unreachable: ${url}`);
305
+ }
306
+ }
307
+
308
+ const playerLine = (p: any) =>
309
+ `script: ${p.state}${p.last ? ` last=${p.last}` : ""}${p.next ? ` next=${p.next}` : ""} steps=${p.steps}`;
310
+
311
+ async function cmdScript(argv: string[]) {
312
+ const { values: o, positionals } = parseArgs({
313
+ args: argv,
314
+ options: { port: { type: "string" }, pane: { type: "string" } },
315
+ allowPositionals: true,
316
+ });
317
+ const [sub, file] = positionals;
318
+ const inst = await resolveTarget(o);
319
+ if (sub === "load") {
320
+ if (!file) fail("usage: script load <file.json>");
321
+ let script: unknown;
322
+ try {
323
+ script = await Bun.file(file!).json();
324
+ } catch (e) {
325
+ fail(`cannot read ${file}: ${(e as Error).message}`);
326
+ }
327
+ const res = await api(inst.url, "/script", { script });
328
+ if (!res.ok) fail(res.error);
329
+ console.log(playerLine(res.player));
330
+ } else if (sub === "show" || !sub) {
331
+ const res = await api(inst.url, "/script");
332
+ console.log(playerLine(res.player));
333
+ for (const [i, st] of (res.script?.steps ?? []).entries()) {
334
+ console.log(`${i + 1}${st.go ? ` go=${st.go}` : ""}${st.hold ? ` hold=${st.hold}s` : ""}`);
335
+ st.say.forEach((l: string, j: number) => {
336
+ const at = `${i + 1}.${j + 1}`;
337
+ const mark = at === res.player.next ? ">" : at === res.player.last ? "*" : " ";
338
+ console.log(` ${mark} ${at} ${l}`);
339
+ });
340
+ }
341
+ } else fail(`unknown script command: ${sub}`);
342
+ }
343
+
344
+ async function cmdPresent(argv: string[]) {
345
+ const { values: o, positionals } = parseArgs({
346
+ args: argv,
347
+ options: { port: { type: "string" }, pane: { type: "string" } },
348
+ allowPositionals: true,
349
+ });
350
+ const inst = await resolveTarget(o);
351
+ const [sub, at] = positionals;
352
+ if (sub === "resume" || sub === "pause" || sub === "goto") {
353
+ if (sub === "goto" && !at) fail("usage: present goto <n[.m]>");
354
+ const res = await api(inst.url, "/player", { cmd: sub, at });
355
+ if (!res.ok) fail(`${res.error}${res.player ? ` (${playerLine(res.player)})` : ""}`);
356
+ console.log(playerLine(res.player));
357
+ return;
358
+ }
359
+ const arg = positionals.join(",");
360
+ let body: object;
361
+ if (arg === "stop") body = { stop: true };
362
+ else if (!arg) body = {};
363
+ else {
364
+ const v = arg.split(/[\s,]+/).filter(Boolean).map(Number);
365
+ if (v.length !== 4 || !v.every(Number.isFinite)) fail(`rect must be x,y,w,h (got "${arg}")`);
366
+ body = { rect: { x: v[0], y: v[1], w: v[2], h: v[3] } };
367
+ }
368
+ let res: any;
369
+ try {
370
+ const r = await fetch(`${inst.url}/present`, {
371
+ method: "POST",
372
+ headers: { "Content-Type": "application/json" },
373
+ body: JSON.stringify(body),
374
+ signal: AbortSignal.timeout(4000),
375
+ });
376
+ res = await r.json();
377
+ } catch {
378
+ fail(`server unreachable: ${inst.url}`);
379
+ }
380
+ if (!res?.ok) fail(res?.error ?? "failed");
381
+ const p = res.present;
382
+ if (!p.on) console.log("presentation: off");
383
+ else console.log(`presentation: on ${p.rect ? `rect=${p.rect.x},${p.rect.y},${p.rect.w},${p.rect.h}` : "(whole screen)"}`);
384
+ }
385
+
282
386
  // ---------- main ----------
283
387
 
284
388
  const [cmd, ...rest] = Bun.argv.slice(2);
@@ -295,6 +399,12 @@ switch (cmd) {
295
399
  case "open":
296
400
  await cmdOpen(rest);
297
401
  break;
402
+ case "present":
403
+ await cmdPresent(rest);
404
+ break;
405
+ case "script":
406
+ await cmdScript(rest);
407
+ break;
298
408
  case "--version":
299
409
  case "-v":
300
410
  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.1",
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/player.ts ADDED
@@ -0,0 +1,234 @@
1
+ // Presentation script player.
2
+ //
3
+ // A script is a list of steps. Each step has an optional `go` (how to bring the
4
+ // screen to that step: "#3" sets location.hash, anything else is evaluated as
5
+ // JS in the page; written by the agent, agello knows nothing about the deck)
6
+ // and lines (`say`), each shown as one bubble.
7
+ //
8
+ // The player owns order and timing: entering a step runs its `go`, then every
9
+ // line is sent and held for its reading time (see PACE). pause() stops at once and keeps
10
+ // the position, resume() re-runs the current step's `go` (the agent may have
11
+ // moved the screen meanwhile) and continues from the next unshown line.
12
+ // goto() moves the position and shows that step without playing.
13
+ //
14
+ // Positions are "step.line", 1-based, in everything that leaves this module.
15
+
16
+ import type { Rect } from "./screencast.ts";
17
+
18
+ export type Step = { go?: string; say: string[]; hold?: number };
19
+ export type Script = { rect?: Rect; steps: Step[] };
20
+ export type PlayerInfo = {
21
+ state: "empty" | "ready" | "playing" | "paused" | "done";
22
+ next?: string; // next line to show ("3.2")
23
+ last?: string; // last line shown
24
+ steps: number;
25
+ };
26
+
27
+ // Pacing: the audience looks at a new screen first, then reads and thinks.
28
+ // go -> 1s -> line (shown >= 10s) -> fades out -> 0.3s -> next line
29
+ // last line -> fades out -> 0.7s -> next step's go
30
+ const MIN_HOLD = 10;
31
+ const MAX_HOLD = 20;
32
+ export const PACE = { afterGo: 1000, fade: 500, betweenLines: 300, afterStep: 700 };
33
+
34
+ // How long one line stays on screen (s): at least 10s, longer text a bit more
35
+ // (about 8 chars/s past the first ~50 chars), at most 20s. `hold` can only
36
+ // lengthen it.
37
+ export function lineHold(text: string, hold?: number): number {
38
+ const auto = Math.min(MAX_HOLD, Math.max(MIN_HOLD, 4 + [...text].length / 8));
39
+ return hold != null && Number.isFinite(hold) && hold > 0 ? Math.max(MIN_HOLD, hold) : auto;
40
+ }
41
+
42
+ export function parseRectString(v: unknown): Rect | undefined {
43
+ if (v && typeof v === "object") {
44
+ const r = v as any;
45
+ return { x: r.x, y: r.y, w: r.w, h: r.h };
46
+ }
47
+ if (typeof v !== "string") return undefined;
48
+ const n = v.split(/[\s,]+/).filter(Boolean).map(Number);
49
+ return n.length === 4 ? { x: n[0], y: n[1], w: n[2], h: n[3] } : undefined;
50
+ }
51
+
52
+ // Validate a script from JSON. Returns the script or an error message.
53
+ export function parseScript(raw: any): Script | string {
54
+ if (!raw || typeof raw !== "object") return "script must be an object";
55
+ if (!Array.isArray(raw.steps) || !raw.steps.length) return "steps must be a non-empty array";
56
+ const steps: Step[] = [];
57
+ for (const [i, s] of raw.steps.entries()) {
58
+ const at = `steps[${i}]`;
59
+ if (!s || typeof s !== "object") return `${at} must be an object`;
60
+ if (s.go != null && typeof s.go !== "string") return `${at}.go must be a string`;
61
+ const say = s.say == null ? [] : Array.isArray(s.say) ? s.say : [s.say];
62
+ if (!say.every((l: unknown) => typeof l === "string" && l.trim())) return `${at}.say must be non-empty strings`;
63
+ if (s.hold != null && !(Number.isFinite(s.hold) && s.hold > 0)) return `${at}.hold must be a positive number`;
64
+ steps.push({ go: s.go || undefined, say: say.map((l: string) => l.trim()), hold: s.hold });
65
+ }
66
+ const rect = raw.rect == null ? undefined : parseRectString(raw.rect);
67
+ if (raw.rect != null && !rect) return "rect must be \"x,y,w,h\" or {x,y,w,h}";
68
+ return { rect, steps };
69
+ }
70
+
71
+ // "3" or "3.2" (1-based) -> 0-based position
72
+ export function parsePos(v: string): { step: number; line: number } | null {
73
+ const m = String(v).trim().match(/^(\d+)(?:\.(\d+))?$/);
74
+ if (!m) return null;
75
+ const step = Number(m[1]) - 1;
76
+ const line = m[2] ? Number(m[2]) - 1 : 0;
77
+ return step >= 0 && line >= 0 ? { step, line } : null;
78
+ }
79
+
80
+ export function createPlayer(deps: {
81
+ go: (expr: string) => Promise<void>;
82
+ say: (text: string, at: string, hold: number) => void;
83
+ done: () => void;
84
+ changed: () => void;
85
+ }) {
86
+ let script: Script | null = null;
87
+ let state: PlayerInfo["state"] = "empty";
88
+ let cur = { step: 0, line: 0 }; // next line to show
89
+ let last: { step: number; line: number } | null = null;
90
+ let entered = false; // `go` of cur.step already run in this play run
91
+ let run = 0; // bumps on every pause/goto/load, stale timers check it
92
+ let timer: Timer | null = null;
93
+
94
+ const fmt = (p: { step: number; line: number }) => `${p.step + 1}.${p.line + 1}`;
95
+ const set = (s: PlayerInfo["state"]) => {
96
+ state = s;
97
+ deps.changed();
98
+ };
99
+ const cancel = () => {
100
+ run++;
101
+ if (timer) clearTimeout(timer);
102
+ timer = null;
103
+ };
104
+ const wait = (ms: number, id: number, fn: () => void) => {
105
+ timer = setTimeout(() => id === run && fn(), ms);
106
+ };
107
+
108
+ function info(): PlayerInfo {
109
+ const steps = script?.steps.length ?? 0;
110
+ // next line as it will be played (a finished step points at the next step)
111
+ let p = cur;
112
+ while (script && p.step < steps && p.line >= Math.max(1, script.steps[p.step].say.length))
113
+ p = { step: p.step + 1, line: 0 };
114
+ return {
115
+ state,
116
+ steps,
117
+ next: script && p.step < steps ? fmt(p) : undefined,
118
+ last: last ? fmt(last) : undefined,
119
+ };
120
+ }
121
+
122
+ // Skip past finished steps (cur.line beyond the step's lines).
123
+ function normalize() {
124
+ if (!script) return;
125
+ while (cur.step < script.steps.length && cur.line >= Math.max(1, script.steps[cur.step].say.length)) {
126
+ cur = { step: cur.step + 1, line: 0 };
127
+ entered = false;
128
+ }
129
+ }
130
+
131
+ async function tick(id: number) {
132
+ if (id !== run || state !== "playing" || !script) return;
133
+ normalize();
134
+ if (cur.step >= script.steps.length) {
135
+ set("done");
136
+ deps.done();
137
+ return;
138
+ }
139
+ const step = script.steps[cur.step];
140
+ if (!entered) {
141
+ entered = true;
142
+ if (step.go) {
143
+ await deps.go(step.go);
144
+ if (id !== run) return;
145
+ wait(PACE.afterGo, id, () => tick(id));
146
+ return;
147
+ }
148
+ }
149
+ if (!step.say.length) {
150
+ // silent step: just show the screen for `hold` seconds
151
+ cur = { step: cur.step, line: 1 };
152
+ deps.changed();
153
+ wait(lineHold("", step.hold) * 1000 + PACE.afterStep, id, () => tick(id));
154
+ return;
155
+ }
156
+ const text = step.say[cur.line];
157
+ const hold = lineHold(text, step.hold);
158
+ const lastLine = cur.line === step.say.length - 1;
159
+ last = { ...cur };
160
+ deps.say(text, fmt(cur), hold);
161
+ cur = { step: cur.step, line: cur.line + 1 };
162
+ deps.changed();
163
+ const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
164
+ wait(hold * 1000 + PACE.fade + gap, id, () => tick(id));
165
+ }
166
+
167
+ return {
168
+ info,
169
+ script: () => script,
170
+
171
+ // Replace the script, keeping the position (clamped) and the state.
172
+ load(next: Script) {
173
+ const playing = state === "playing";
174
+ cancel();
175
+ script = next;
176
+ if (cur.step > next.steps.length) cur = { step: next.steps.length, line: 0 };
177
+ normalize();
178
+ if (state === "empty") state = "ready";
179
+ if (state === "done" && cur.step < next.steps.length) state = "paused";
180
+ if (playing) {
181
+ entered = true; // screen is already on this step
182
+ tick(run);
183
+ }
184
+ deps.changed();
185
+ },
186
+
187
+ resume(): string | null {
188
+ if (!script) return "no_script";
189
+ if (state === "playing") return null;
190
+ if (state === "done") return "done";
191
+ cancel();
192
+ entered = false; // bring the screen back to the current step first
193
+ set("playing");
194
+ tick(run);
195
+ return null;
196
+ },
197
+
198
+ pause() {
199
+ if (state !== "playing") return;
200
+ cancel();
201
+ set("paused");
202
+ },
203
+
204
+ // Move to a step (and line) and show its screen. Does not play; if it was
205
+ // playing, it keeps playing from there.
206
+ async goto(pos: { step: number; line: number }): Promise<string | null> {
207
+ if (!script) return "no_script";
208
+ const step = script.steps[pos.step];
209
+ if (!step) return "no_such_step";
210
+ if (pos.line > 0 && pos.line >= step.say.length) return "no_such_line";
211
+ const playing = state === "playing";
212
+ cancel();
213
+ cur = { ...pos };
214
+ entered = false;
215
+ if (playing) {
216
+ tick(run);
217
+ return null;
218
+ }
219
+ if (state !== "ready") state = "paused";
220
+ if (step.go) await deps.go(step.go);
221
+ entered = true;
222
+ deps.changed();
223
+ return null;
224
+ },
225
+
226
+ // Presentation ended: stop where it is.
227
+ stop() {
228
+ if (state === "playing") {
229
+ cancel();
230
+ set("paused");
231
+ }
232
+ },
233
+ };
234
+ }
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,12 @@ 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
+ // Run JS in the relayed tab (presentation script `go`). Needs the relay to be
272
+ // connected, i.e. at least one viewer. Returns false if it could not run.
273
+ async function evaluate(expression: string): Promise<boolean> {
274
+ const r = await request("Runtime.evaluate", { expression, awaitPromise: true });
275
+ return !!r && !r.exceptionDetails;
276
+ }
277
+
278
+ return { handle, input, describe, setClip, evaluate };
196
279
  }
package/src/server.ts CHANGED
@@ -7,11 +7,19 @@
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")
14
+ // GET /script presentation script + player position
15
+ // POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
16
+ // POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
10
17
 
11
18
  import { homedir } from "node:os";
12
19
  import { join } from "node:path";
13
20
  import { runJson } from "./run.ts";
14
- import { createScreencast } from "./screencast.ts";
21
+ import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
22
+ import { createScreencast, type Rect } from "./screencast.ts";
15
23
 
16
24
  export type ServerOptions = {
17
25
  port: number;
@@ -77,6 +85,17 @@ async function herdr(timeout: number, ...cmd: string[]): Promise<any> {
77
85
  return (await runJson(["herdr", ...cmd], timeout)) ?? { error: { code: "herdr_failed" } };
78
86
  }
79
87
 
88
+ // Presentation rect from the API: finite, non-negative origin, positive size (CSS px).
89
+ export function parseRect(r: any): Rect | null {
90
+ if (!r || typeof r !== "object") return null;
91
+ const v = [r.x, r.y, r.w, r.h].map(Number);
92
+ if (!v.every(Number.isFinite) || v[0] < 0 || v[1] < 0 || v[2] < 1 || v[3] < 1) return null;
93
+ const [x, y, w, h] = v.map(Math.round);
94
+ return { x, y, w, h };
95
+ }
96
+
97
+ export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
98
+
80
99
  type Status =
81
100
  | { alive: false; reason: string; pane: string }
82
101
  | { alive: true; status: string; pane: string; label?: string; session: string; title?: string };
@@ -291,6 +310,98 @@ export async function startServer(opts: ServerOptions) {
291
310
  herdrTab: async () => (await agentInfo())?.tab_id,
292
311
  });
293
312
 
313
+ // ----- presentation mode -----
314
+ // on: viewers show only the screen, full size, with agent replies as
315
+ // short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
316
+
317
+ let present: Present = { on: false };
318
+ const presentView = (): Present => ({ ...present, player: player.info() });
319
+ function setPresent(next: Present) {
320
+ present = next;
321
+ screen.setClip(next.on ? (next.rect ?? null) : null);
322
+ if (!next.on) player.stop();
323
+ broadcast("present", presentView());
324
+ }
325
+
326
+ // Script player: agello owns order and timing, the agent owns the deck
327
+ // (each step's `go`) and decides when to resume after a question.
328
+ const player = createPlayer({
329
+ async go(expr) {
330
+ const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
331
+ if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
332
+ },
333
+ say(text, at, hold) {
334
+ broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
335
+ },
336
+ done() {
337
+ const n = player.info().steps;
338
+ promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
339
+ },
340
+ changed() {
341
+ broadcast("present", presentView());
342
+ },
343
+ });
344
+
345
+ // "마지막 표시 3.2 · 다음 3.3" for agent notices
346
+ const where = () => {
347
+ const i = player.info();
348
+ if (i.state === "empty") return "";
349
+ return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
350
+ };
351
+
352
+ async function scriptRequest(req: Request): Promise<Response> {
353
+ if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
354
+ const data = (await req.json().catch(() => ({}))) as { script?: unknown };
355
+ const script = parseScript(data.script);
356
+ if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
357
+ if (script.rect && !parseRect(script.rect))
358
+ return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
359
+ player.load(script);
360
+ return Response.json({ ok: true, player: player.info() });
361
+ }
362
+
363
+ async function playerRequest(req: Request): Promise<Response> {
364
+ const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
365
+ let error: string | null = null;
366
+ if (data.cmd === "resume") {
367
+ if (!present.on) {
368
+ const rect = player.script()?.rect;
369
+ setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
370
+ }
371
+ error = player.resume();
372
+ } else if (data.cmd === "pause") player.pause();
373
+ else if (data.cmd === "goto") {
374
+ const pos = parsePos(data.at ?? "");
375
+ error = pos ? await player.goto(pos) : "invalid_position";
376
+ } else error = "unknown_cmd";
377
+ if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
378
+ return Response.json({ ok: true, player: player.info() });
379
+ }
380
+
381
+ async function presentRequest(req: Request): Promise<Response> {
382
+ const data = (await req.json().catch(() => ({}))) as { rect?: unknown; stop?: boolean; from?: string };
383
+ if (data.stop) {
384
+ const was = present.on;
385
+ setPresent({ on: false });
386
+ // Ended from the page: the agent may still be presenting, so tell it.
387
+ // (The CLI stop comes from the agent itself and needs no notice.)
388
+ let notified: string | undefined;
389
+ if (was && data.from === "viewer") {
390
+ const at = where();
391
+ const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
392
+ notified = r.ok ? "ok" : r.error;
393
+ }
394
+ return Response.json({ ok: true, present: presentView(), notified });
395
+ }
396
+ let rect: Rect | undefined;
397
+ if (data.rect != null) {
398
+ rect = parseRect(data.rect) ?? undefined;
399
+ if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
400
+ }
401
+ setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
402
+ return Response.json({ ok: true, present: presentView() });
403
+ }
404
+
294
405
  // ----- HTTP -----
295
406
 
296
407
  // Same-origin and localhost pages are allowed by default; add more with
@@ -329,6 +440,7 @@ export async function startServer(opts: ServerOptions) {
329
440
  tools: [...pendingTools.keys()],
330
441
  });
331
442
  if (lastStatus) send(c, "status", lastStatus);
443
+ send(c, "present", presentView());
332
444
  for (const t of pendingTools.values()) send(c, "tool_start", t);
333
445
  ping = setInterval(() => send(c, "ping", {}), 15000);
334
446
  },
@@ -342,22 +454,40 @@ export async function startServer(opts: ServerOptions) {
342
454
  });
343
455
  }
344
456
 
345
- async function sendPrompt(req: Request): Promise<Response> {
346
- const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
457
+ // Type a [browser] prompt into the agent session.
458
+ async function promptAgent(action: string, text: string): Promise<{ ok: boolean; error?: string; status?: number }> {
347
459
  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 });
460
+ if (!st.alive) return { ok: false, error: st.reason, status: 409 };
461
+ if (st.status === "blocked") return { ok: false, error: "agent_blocked", status: 409 };
462
+ const res = await herdr(10000, "agent", "prompt", PANE, formatPrompt(action, text.trim()));
463
+ if (res?.error) return { ok: false, error: res.error.code, status: 502 };
464
+ return { ok: true };
465
+ }
350
466
 
467
+ async function sendPrompt(req: Request): Promise<Response> {
468
+ const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
351
469
  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 });
470
+ let text = data.text ?? "";
471
+ // Raised hand: stop the script at once (no agent round trip) and tell the
472
+ // agent where it stopped.
473
+ if (action === "hand-raise") {
474
+ player.pause();
475
+ const at = where();
476
+ if (at) text += ` (대본 정지: ${at})`;
477
+ }
478
+ const r = await promptAgent(action, text);
479
+ return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
355
480
  }
356
481
 
357
482
  async function route(req: Request, url: URL): Promise<Response> {
358
483
  switch (url.pathname) {
359
484
  case "/status":
360
- return Response.json({ ...(await checkStatus()), browser: opts.browser, screen: await screen.describe() });
485
+ return Response.json({
486
+ ...(await checkStatus()),
487
+ browser: opts.browser,
488
+ screen: await screen.describe(),
489
+ present: presentView(),
490
+ });
361
491
  case "/events":
362
492
  return events();
363
493
  case "/screen":
@@ -368,6 +498,14 @@ export async function startServer(opts: ServerOptions) {
368
498
  });
369
499
  case "/send":
370
500
  if (req.method === "POST") return sendPrompt(req);
501
+ break;
502
+ case "/present":
503
+ return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
504
+ case "/script":
505
+ return scriptRequest(req);
506
+ case "/player":
507
+ if (req.method === "POST") return playerRequest(req);
508
+ break;
371
509
  }
372
510
  return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
373
511
  }
@@ -23,10 +23,27 @@
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.
39
+ // Script lines (`agello script` + `present resume`) arrive as agent messages
40
+ // with `script: "step.line"`. While the script is not playing and the agent
41
+ // is working (e.g. answering a question), a small loader is shown.
26
42
  //
27
43
  // Methods
28
44
  // el.send(text, action = 'message') -> Promise<{ok, error?}>
29
45
  // el.clearHistory() clear saved chat for the current pane
46
+ // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
30
47
 
31
48
  const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
32
49
 
@@ -53,7 +70,10 @@ const SCREEN_REASONS = {
53
70
  tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
54
71
  no_active_tab: "브라우저에 활성 탭 없음",
55
72
  };
56
- const ACTIONS = { message: "메시지", approve: "승인", reject: "거절" };
73
+ const ACTIONS = {
74
+ message: "메시지", approve: "승인", reject: "거절",
75
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
76
+ };
57
77
 
58
78
  const CSS = `
59
79
  :host {
@@ -154,6 +174,68 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
154
174
  .control-btn:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; border-radius: 6px; }
155
175
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
156
176
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
177
+
178
+ /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
179
+ container-type would make the host the containing block of the fixed layer, so drop it. */
180
+ :host([presenting]) { container-type: normal; }
181
+ .body.present .screen { display: flex; position: fixed; inset: 0; z-index: 2147483000; border: 0;
182
+ width: auto; height: auto; background: #0b0b0c; }
183
+ .body.present .screen-bar, .body.present .ring, .body.present .glow, .body.present .agent-badge { display: none; }
184
+ .body.present .viewport { padding: 0; background: #0b0b0c; }
185
+ .body.present .viewport img { width: 100%; height: 100%; max-width: none; max-height: none; border-radius: 0;
186
+ box-shadow: none; }
187
+ .captions { display: none; }
188
+ .body.present .captions { position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; display: flex;
189
+ flex-direction: column; align-items: center; gap: 10px; padding: 0 88px 28px; pointer-events: none; }
190
+ .caption { max-width: min(900px, 86%); padding: 12px 18px; border-radius: 16px; overflow-wrap: anywhere;
191
+ font-size: 18px; line-height: 1.5; color: #fff; background: rgba(20, 20, 22, .72);
192
+ -webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
193
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
194
+ .caption.plain { white-space: pre-wrap; }
195
+ .caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
196
+ .caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
197
+ .caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
198
+ .caption p, .caption ul, .caption ol { margin: .35em 0; }
199
+ .caption code { font-family: ui-monospace, Menlo, monospace; font-size: .85em; background: rgba(255,255,255,.14);
200
+ padding: 1px 5px; border-radius: 4px; }
201
+ .caption pre { background: rgba(255,255,255,.1); padding: 8px 10px; border-radius: 8px; overflow-x: auto; }
202
+ .caption a { color: inherit; }
203
+ @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
204
+
205
+ /* agent busy while presenting (answering, fixing the script): minimal loader */
206
+ .busy { display: none; }
207
+ .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
208
+ gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
209
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
210
+ animation: capIn .25s ease; }
211
+ .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
212
+
213
+ /* raise hand: ask the agent mid-presentation */
214
+ .hand-wrap { display: none; }
215
+ .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
216
+ flex-direction: column; align-items: flex-end; gap: 10px; }
217
+ .hand, .end { width: 48px; height: 48px; padding: 0; border-radius: 50%; display: grid; place-items: center;
218
+ color: #fff; border: 1px solid rgba(255,255,255,.18); background: rgba(20, 20, 22, .72);
219
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
220
+ transition: background .2s ease, transform .2s ease; }
221
+ .hand:hover, .end:hover { background: rgba(40, 40, 44, .85); transform: translateY(-2px); }
222
+ .hand[aria-expanded="true"] { background: var(--_accent); border-color: var(--_accent); }
223
+ .hand.sent { background: var(--_ok); border-color: var(--_ok); }
224
+ .end:hover { background: var(--_dead); border-color: var(--_dead); }
225
+ .end:disabled { opacity: .5; cursor: progress; }
226
+ .hand:focus-visible, .end:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
227
+ .hand svg, .end svg { width: 22px; height: 22px; }
228
+ .ask { width: min(420px, calc(100vw - 44px)); padding: 12px; border-radius: 16px; color: #fff;
229
+ background: rgba(20, 20, 22, .82); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
230
+ box-shadow: 0 8px 30px rgba(0,0,0,.4); animation: capIn .2s ease; }
231
+ .ask[hidden] { display: none; }
232
+ .ask textarea { min-height: 64px; background: rgba(255,255,255,.08); color: #fff;
233
+ border-color: rgba(255,255,255,.16); font-size: 15px; }
234
+ .ask textarea::placeholder { color: rgba(255,255,255,.5); }
235
+ .ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
236
+ .ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
237
+ @media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
238
+ @media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
157
239
  @container (max-width: 820px) {
158
240
  .body { flex-direction: column; }
159
241
  :host([screen]) .screen { order: -1; flex: none; height: 45%; border-left: 0; border-bottom: 1px solid var(--_line); }
@@ -256,7 +338,18 @@ const TEMPLATE = `
256
338
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
257
339
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
258
340
  <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>
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>
342
+ <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
343
+ <div class="hand-wrap">
344
+ <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
345
+ <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
346
+ <button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
347
+ <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>
348
+ </button>
349
+ <button class="hand" part="hand" type="button" aria-expanded="false" aria-label="손들기: 에이전트에게 질문" title="손들기: 에이전트에게 질문">
350
+ <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>
351
+ </button>
352
+ </div></div>
260
353
  </aside></div>`;
261
354
 
262
355
  class AgentBridge extends HTMLElement {
@@ -273,7 +366,10 @@ class AgentBridge extends HTMLElement {
273
366
  #md = null;
274
367
  #historyKey = null;
275
368
  #history = [];
369
+ #present = { on: false };
276
370
  static HISTORY_LIMIT = 500;
371
+ static CAPTION_MS = 10000; // presentation bubble lifetime
372
+ static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
277
373
 
278
374
  constructor() {
279
375
  super();
@@ -285,6 +381,8 @@ class AgentBridge extends HTMLElement {
285
381
  buttons: this.#root.querySelectorAll("button[data-action]"),
286
382
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
287
383
  controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
384
+ body: q(".body"), screen: q(".screen"), captions: q(".captions"),
385
+ busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
288
386
  };
289
387
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
290
388
  this.#bindGrip();
@@ -296,6 +394,7 @@ class AgentBridge extends HTMLElement {
296
394
  });
297
395
  loadMarkdown().then((md) => (this.#md = md));
298
396
  this.#bindControl();
397
+ this.#bindHand();
299
398
  this.$.clearBtn.addEventListener("click", () => this.clearHistory());
300
399
  }
301
400
 
@@ -334,6 +433,19 @@ class AgentBridge extends HTMLElement {
334
433
  }
335
434
  }
336
435
 
436
+ async stopPresent() {
437
+ try {
438
+ const res = await fetch(`${this.server}/present`, {
439
+ method: "POST",
440
+ headers: { "Content-Type": "application/json" },
441
+ body: JSON.stringify({ stop: true, from: "viewer" }),
442
+ });
443
+ return await res.json();
444
+ } catch {
445
+ return { ok: false, error: "server_unreachable" };
446
+ }
447
+ }
448
+
337
449
  #emit(type, detail) {
338
450
  this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
339
451
  }
@@ -349,7 +461,15 @@ class AgentBridge extends HTMLElement {
349
461
  on("delivered", ({ id }) => { this.#deliver(id); this.#emit("agent-queue", { phase: "delivered", id }); });
350
462
  on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
351
463
  on("status", (s) => this.#renderStatus(s));
352
- on("message", (m) => { this.#addMessage(m); this.#saveMessage(m); this.#emit("agent-message", m); });
464
+ on("message", (m) => {
465
+ this.#addMessage(m);
466
+ this.#saveMessage(m);
467
+ // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
468
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
469
+ if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
470
+ this.#emit("agent-message", m);
471
+ });
472
+ on("present", (p) => this.#setPresent(p));
353
473
  on("tool_start", (t) => {
354
474
  this.#tools.set(t.id, t);
355
475
  this.#renderActivity();
@@ -366,11 +486,11 @@ class AgentBridge extends HTMLElement {
366
486
  this.#setToolState(t.id, t.isError ? "error" : "done");
367
487
  this.#emit("agent-tool", { phase: "end", ...t });
368
488
  });
369
- es.onerror = () => this.#renderStatus(null);
489
+ es.onerror = () => { this.#renderStatus(null); this.#setPresent({ on: false }); };
370
490
  }
371
491
 
372
492
  #syncScreen() {
373
- const want = this.hasAttribute("screen");
493
+ const want = this.hasAttribute("screen") || this.#present.on;
374
494
  if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
375
495
  if (this.#screenEs) return;
376
496
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
@@ -384,6 +504,117 @@ class AgentBridge extends HTMLElement {
384
504
  es.onerror = () => this.#renderScreenMeta({ connected: false });
385
505
  }
386
506
 
507
+ // ---------- presentation mode ----------
508
+
509
+ #setPresent(p) {
510
+ const on = !!p?.on;
511
+ const was = this.#present.on;
512
+ this.#present = p ?? { on: false };
513
+ this.#emit("agent-present", this.#present);
514
+ this.#renderBusy();
515
+ if (on === was) return;
516
+ if (on) this.#setControl(false);
517
+ else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
518
+ this.#syncScreen();
519
+ // FLIP: grow from (or shrink back to) the side panel
520
+ const { screen, body } = this.$;
521
+ const before = screen.getBoundingClientRect();
522
+ body.classList.toggle("present", on);
523
+ this.toggleAttribute("presenting", on);
524
+ const after = screen.getBoundingClientRect();
525
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches || !before.width || !after.width) return;
526
+ const t = `translate(${before.left - after.left}px, ${before.top - after.top}px) ` +
527
+ `scale(${before.width / after.width}, ${before.height / after.height})`;
528
+ screen.animate([{ transformOrigin: "0 0", transform: t }, { transformOrigin: "0 0", transform: "none" }],
529
+ { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
530
+ }
531
+
532
+ #renderBusy() {
533
+ const s = this.#status;
534
+ const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
535
+ this.$.busy.classList.toggle("on", !!busy);
536
+ }
537
+
538
+ // Raise hand: open a small input over the presentation and send it as a message.
539
+ #bindHand() {
540
+ const { hand, ask, askBox, askHint } = this.$;
541
+ hand.addEventListener("click", () => {
542
+ if (ask.hidden) this.#raiseHand();
543
+ else this.#lowerHand();
544
+ });
545
+ this.$.end.addEventListener("click", async () => {
546
+ this.$.end.disabled = true;
547
+ const res = await this.stopPresent();
548
+ this.$.end.disabled = false;
549
+ if (!res.ok) { this.#openAsk(true); askHint.textContent = `발표 종료 실패: ${res.error}`; }
550
+ });
551
+ ask.addEventListener("submit", async (e) => {
552
+ e.preventDefault();
553
+ const text = askBox.value.trim();
554
+ if (!text) return;
555
+ askHint.textContent = "보내는 중…";
556
+ const res = await this.send(text, "message");
557
+ if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
558
+ askBox.value = "";
559
+ this.#handUp = false; // the question itself ends the raised hand
560
+ this.#openAsk(false);
561
+ hand.classList.add("sent");
562
+ setTimeout(() => hand.classList.remove("sent"), 1500);
563
+ });
564
+ askBox.addEventListener("keydown", (e) => {
565
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
566
+ if (e.key === "Escape") { e.preventDefault(); this.#lowerHand(); hand.focus(); }
567
+ });
568
+ }
569
+
570
+ // Raise: open the input and tell the agent to pause. Lower (closed without
571
+ // asking): tell it to go on. Failures only show a hint; asking still works.
572
+ #handUp = false;
573
+ async #raiseHand() {
574
+ this.#openAsk(true);
575
+ if (this.#handUp) return;
576
+ this.#handUp = true;
577
+ const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
578
+ if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
579
+ }
580
+ async #lowerHand() {
581
+ this.#openAsk(false);
582
+ if (!this.#handUp) return;
583
+ this.#handUp = false;
584
+ await this.send("사용자가 질문 없이 손을 내림", "hand-lower");
585
+ }
586
+
587
+ #openAsk(open) {
588
+ const { hand, ask, askBox, askHint } = this.$;
589
+ ask.hidden = !open;
590
+ hand.setAttribute("aria-expanded", String(open));
591
+ askHint.textContent = "";
592
+ if (open) askBox.focus();
593
+ }
594
+
595
+ // Agent reply as a short-lived bubble over the presented screen.
596
+ // kind "user": the viewer's own question (plain text, accent colour).
597
+ #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
598
+ if (!text?.trim()) return;
599
+ const el = document.createElement("div");
600
+ el.className = `caption ${kind}`;
601
+ el.part = "caption";
602
+ if (kind === "user") { el.textContent = text; el.classList.add("plain"); }
603
+ else if (this.#md) el.innerHTML = this.#md(text);
604
+ else { el.textContent = text; el.classList.add("plain"); }
605
+ const box = this.$.captions;
606
+ box.append(el);
607
+ const leave = () => {
608
+ if (el.classList.contains("out")) return;
609
+ el.classList.add("out");
610
+ setTimeout(() => el.remove(), 500);
611
+ };
612
+ setTimeout(leave, ms);
613
+ const live = [...box.children].filter((c) => !c.classList.contains("out"));
614
+ live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
615
+ el.__leave = leave;
616
+ }
617
+
387
618
  // ---------- composer resize grip (top edge) ----------
388
619
 
389
620
  #bindGrip() {
@@ -456,7 +687,7 @@ class AgentBridge extends HTMLElement {
456
687
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
457
688
  const BUTTONS = ["left", "middle", "right"];
458
689
 
459
- controlBtn.addEventListener("click", () => this.#setControl(!this.#controlWs));
690
+ controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
460
691
 
461
692
  img.addEventListener("mousedown", (e) => {
462
693
  if (!on()) return;
@@ -673,7 +904,7 @@ class AgentBridge extends HTMLElement {
673
904
  return;
674
905
  } else if (m.role === "assistant") {
675
906
  wrap.className = "msg assistant";
676
- meta.textContent = `에이전트 · ${time}`;
907
+ meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
677
908
  if (this.#md) bubble.innerHTML = this.#md(m.text);
678
909
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
679
910
  } else if (m.role === "browser") {
@@ -735,6 +966,7 @@ class AgentBridge extends HTMLElement {
735
966
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
736
967
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
737
968
  this.#renderActivity();
969
+ this.#renderBusy();
738
970
  this.#emit("agent-status", s);
739
971
  }
740
972