agello 0.4.1 → 0.4.3
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 +35 -1
- package/bin/agello.ts +68 -0
- package/package.json +1 -1
- package/src/player.ts +244 -0
- package/src/screencast.ts +71 -1
- package/src/server.ts +129 -15
- package/web/agent-bridge.js +372 -54
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
- Conversation saved per pane in the browser, kept across reloads
|
|
8
8
|
- Interactive terminal view of the existing herdr pane: live cursor, keyboard input, and automatic resize
|
|
9
9
|
- Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
|
|
10
|
+
- Annotate: point at an element on that screen and send a request about it, with its CSS selector
|
|
10
11
|
- Embeddable `<agent-bridge>` web component
|
|
11
12
|
|
|
12
13
|
Chat currently supports **Claude Code** sessions. The terminal view connects directly to the pane, including ordinary shells and other terminal applications.
|
|
@@ -60,6 +61,18 @@ Messages sent from the page arrive in the agent as:
|
|
|
60
61
|
|
|
61
62
|
The approve / reject buttons send `action=approve` / `action=reject`.
|
|
62
63
|
|
|
64
|
+
### Annotate
|
|
65
|
+
|
|
66
|
+
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:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[browser] action=annotate make this red / and bolder
|
|
70
|
+
대상: #app > main > button:nth-of-type(2)
|
|
71
|
+
요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
63
76
|
### Commands
|
|
64
77
|
|
|
65
78
|
| Command | Description |
|
|
@@ -70,6 +83,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
|
|
|
70
83
|
| `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
|
|
71
84
|
| `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
|
|
72
85
|
| `agello present stop` | End presentation mode |
|
|
86
|
+
| `agello script load <file> \| show` | Load / show a presentation script |
|
|
87
|
+
| `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
|
|
73
88
|
|
|
74
89
|
`start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
|
|
75
90
|
|
|
@@ -98,10 +113,29 @@ The xterm.js renderer and its styles are served locally; no terminal CDN is requ
|
|
|
98
113
|
- agent replies appear over it as bubbles that fade after 10s
|
|
99
114
|
- `agello present stop` returns to the normal layout; the replies are in the chat as usual
|
|
100
115
|
- 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`
|
|
101
|
-
- raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing
|
|
116
|
+
- raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing it with X (or Esc) before asking sends `action=hand-lower`
|
|
117
|
+
- the input stays open for the whole question: after the agent answers, follow-up questions go in the same input (no need to raise the hand again); X (or Esc) then sends `action=hand-done`, and the agent resumes
|
|
102
118
|
|
|
103
119
|
Run `present` again to move the crop. User control is off while presenting.
|
|
104
120
|
|
|
121
|
+
#### Scripts
|
|
122
|
+
|
|
123
|
+
Write the talk ahead so each line appears the moment its screen does:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{ "rect": "28,157,688,477",
|
|
127
|
+
"steps": [
|
|
128
|
+
{ "go": "#1", "say": ["First line", "Second line"] },
|
|
129
|
+
{ "go": "#2", "say": ["..."], "hold": 6 } ] }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- 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
|
|
133
|
+
- `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
|
|
134
|
+
- raising a hand pauses at once and rewinds one line, so `present resume` replays the interrupted line; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.1`)
|
|
135
|
+
- raising, cancelling (X before asking) and finishing a question (X after an answer) show a small light event bubble for 5s right away, apart from the conversation bubbles; sending a question shows none
|
|
136
|
+
- `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
|
|
137
|
+
- 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
|
|
138
|
+
|
|
105
139
|
## Embed
|
|
106
140
|
|
|
107
141
|
```html
|
package/bin/agello.ts
CHANGED
|
@@ -27,6 +27,13 @@ Usage:
|
|
|
27
27
|
whole screen), with agent replies as bubbles that fade after 10s.
|
|
28
28
|
Run again to move the crop.
|
|
29
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
|
|
30
37
|
${NAME} help | --version
|
|
31
38
|
|
|
32
39
|
start options:
|
|
@@ -284,6 +291,56 @@ async function cmdOpen(argv: string[]) {
|
|
|
284
291
|
console.log(inst.url);
|
|
285
292
|
}
|
|
286
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
|
+
|
|
287
344
|
async function cmdPresent(argv: string[]) {
|
|
288
345
|
const { values: o, positionals } = parseArgs({
|
|
289
346
|
args: argv,
|
|
@@ -291,6 +348,14 @@ async function cmdPresent(argv: string[]) {
|
|
|
291
348
|
allowPositionals: true,
|
|
292
349
|
});
|
|
293
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
|
+
}
|
|
294
359
|
const arg = positionals.join(",");
|
|
295
360
|
let body: object;
|
|
296
361
|
if (arg === "stop") body = { stop: true };
|
|
@@ -337,6 +402,9 @@ switch (cmd) {
|
|
|
337
402
|
case "present":
|
|
338
403
|
await cmdPresent(rest);
|
|
339
404
|
break;
|
|
405
|
+
case "script":
|
|
406
|
+
await cmdScript(rest);
|
|
407
|
+
break;
|
|
340
408
|
case "--version":
|
|
341
409
|
case "-v":
|
|
342
410
|
console.log(pkg.version);
|
package/package.json
CHANGED
package/src/player.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
// rewind() (after a raised hand) moves back so the last shown line plays again.
|
|
13
|
+
// goto() moves the position and shows that step without playing.
|
|
14
|
+
//
|
|
15
|
+
// Positions are "step.line", 1-based, in everything that leaves this module.
|
|
16
|
+
|
|
17
|
+
import type { Rect } from "./screencast.ts";
|
|
18
|
+
|
|
19
|
+
export type Step = { go?: string; say: string[]; hold?: number };
|
|
20
|
+
export type Script = { rect?: Rect; steps: Step[] };
|
|
21
|
+
export type PlayerInfo = {
|
|
22
|
+
state: "empty" | "ready" | "playing" | "paused" | "done";
|
|
23
|
+
next?: string; // next line to show ("3.2")
|
|
24
|
+
last?: string; // last line shown
|
|
25
|
+
steps: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Pacing: the audience looks at a new screen first, then reads and thinks.
|
|
29
|
+
// go -> 1s -> line (shown >= 10s) -> fades out -> 0.3s -> next line
|
|
30
|
+
// last line -> fades out -> 0.7s -> next step's go
|
|
31
|
+
const MIN_HOLD = 10;
|
|
32
|
+
const MAX_HOLD = 20;
|
|
33
|
+
export const PACE = { afterGo: 1000, fade: 500, betweenLines: 300, afterStep: 700 };
|
|
34
|
+
|
|
35
|
+
// How long one line stays on screen (s): at least 10s, longer text a bit more
|
|
36
|
+
// (about 8 chars/s past the first ~50 chars), at most 20s. `hold` can only
|
|
37
|
+
// lengthen it.
|
|
38
|
+
export function lineHold(text: string, hold?: number): number {
|
|
39
|
+
const auto = Math.min(MAX_HOLD, Math.max(MIN_HOLD, 4 + [...text].length / 8));
|
|
40
|
+
return hold != null && Number.isFinite(hold) && hold > 0 ? Math.max(MIN_HOLD, hold) : auto;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseRectString(v: unknown): Rect | undefined {
|
|
44
|
+
if (v && typeof v === "object") {
|
|
45
|
+
const r = v as any;
|
|
46
|
+
return { x: r.x, y: r.y, w: r.w, h: r.h };
|
|
47
|
+
}
|
|
48
|
+
if (typeof v !== "string") return undefined;
|
|
49
|
+
const n = v.split(/[\s,]+/).filter(Boolean).map(Number);
|
|
50
|
+
return n.length === 4 ? { x: n[0], y: n[1], w: n[2], h: n[3] } : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Validate a script from JSON. Returns the script or an error message.
|
|
54
|
+
export function parseScript(raw: any): Script | string {
|
|
55
|
+
if (!raw || typeof raw !== "object") return "script must be an object";
|
|
56
|
+
if (!Array.isArray(raw.steps) || !raw.steps.length) return "steps must be a non-empty array";
|
|
57
|
+
const steps: Step[] = [];
|
|
58
|
+
for (const [i, s] of raw.steps.entries()) {
|
|
59
|
+
const at = `steps[${i}]`;
|
|
60
|
+
if (!s || typeof s !== "object") return `${at} must be an object`;
|
|
61
|
+
if (s.go != null && typeof s.go !== "string") return `${at}.go must be a string`;
|
|
62
|
+
const say = s.say == null ? [] : Array.isArray(s.say) ? s.say : [s.say];
|
|
63
|
+
if (!say.every((l: unknown) => typeof l === "string" && l.trim())) return `${at}.say must be non-empty strings`;
|
|
64
|
+
if (s.hold != null && !(Number.isFinite(s.hold) && s.hold > 0)) return `${at}.hold must be a positive number`;
|
|
65
|
+
steps.push({ go: s.go || undefined, say: say.map((l: string) => l.trim()), hold: s.hold });
|
|
66
|
+
}
|
|
67
|
+
const rect = raw.rect == null ? undefined : parseRectString(raw.rect);
|
|
68
|
+
if (raw.rect != null && !rect) return "rect must be \"x,y,w,h\" or {x,y,w,h}";
|
|
69
|
+
return { rect, steps };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// "3" or "3.2" (1-based) -> 0-based position
|
|
73
|
+
export function parsePos(v: string): { step: number; line: number } | null {
|
|
74
|
+
const m = String(v).trim().match(/^(\d+)(?:\.(\d+))?$/);
|
|
75
|
+
if (!m) return null;
|
|
76
|
+
const step = Number(m[1]) - 1;
|
|
77
|
+
const line = m[2] ? Number(m[2]) - 1 : 0;
|
|
78
|
+
return step >= 0 && line >= 0 ? { step, line } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createPlayer(deps: {
|
|
82
|
+
go: (expr: string) => Promise<void>;
|
|
83
|
+
say: (text: string, at: string, hold: number) => void;
|
|
84
|
+
done: () => void;
|
|
85
|
+
changed: () => void;
|
|
86
|
+
}) {
|
|
87
|
+
let script: Script | null = null;
|
|
88
|
+
let state: PlayerInfo["state"] = "empty";
|
|
89
|
+
let cur = { step: 0, line: 0 }; // next line to show
|
|
90
|
+
let last: { step: number; line: number } | null = null;
|
|
91
|
+
let entered = false; // `go` of cur.step already run in this play run
|
|
92
|
+
let run = 0; // bumps on every pause/goto/load, stale timers check it
|
|
93
|
+
let timer: Timer | null = null;
|
|
94
|
+
|
|
95
|
+
const fmt = (p: { step: number; line: number }) => `${p.step + 1}.${p.line + 1}`;
|
|
96
|
+
const set = (s: PlayerInfo["state"]) => {
|
|
97
|
+
state = s;
|
|
98
|
+
deps.changed();
|
|
99
|
+
};
|
|
100
|
+
const cancel = () => {
|
|
101
|
+
run++;
|
|
102
|
+
if (timer) clearTimeout(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
};
|
|
105
|
+
const wait = (ms: number, id: number, fn: () => void) => {
|
|
106
|
+
timer = setTimeout(() => id === run && fn(), ms);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function info(): PlayerInfo {
|
|
110
|
+
const steps = script?.steps.length ?? 0;
|
|
111
|
+
// next line as it will be played (a finished step points at the next step)
|
|
112
|
+
let p = cur;
|
|
113
|
+
while (script && p.step < steps && p.line >= Math.max(1, script.steps[p.step].say.length))
|
|
114
|
+
p = { step: p.step + 1, line: 0 };
|
|
115
|
+
return {
|
|
116
|
+
state,
|
|
117
|
+
steps,
|
|
118
|
+
next: script && p.step < steps ? fmt(p) : undefined,
|
|
119
|
+
last: last ? fmt(last) : undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Skip past finished steps (cur.line beyond the step's lines).
|
|
124
|
+
function normalize() {
|
|
125
|
+
if (!script) return;
|
|
126
|
+
while (cur.step < script.steps.length && cur.line >= Math.max(1, script.steps[cur.step].say.length)) {
|
|
127
|
+
cur = { step: cur.step + 1, line: 0 };
|
|
128
|
+
entered = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function tick(id: number) {
|
|
133
|
+
if (id !== run || state !== "playing" || !script) return;
|
|
134
|
+
normalize();
|
|
135
|
+
if (cur.step >= script.steps.length) {
|
|
136
|
+
set("done");
|
|
137
|
+
deps.done();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const step = script.steps[cur.step];
|
|
141
|
+
if (!entered) {
|
|
142
|
+
entered = true;
|
|
143
|
+
if (step.go) {
|
|
144
|
+
await deps.go(step.go);
|
|
145
|
+
if (id !== run) return;
|
|
146
|
+
wait(PACE.afterGo, id, () => tick(id));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!step.say.length) {
|
|
151
|
+
// silent step: just show the screen for `hold` seconds
|
|
152
|
+
cur = { step: cur.step, line: 1 };
|
|
153
|
+
deps.changed();
|
|
154
|
+
wait(lineHold("", step.hold) * 1000 + PACE.afterStep, id, () => tick(id));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const text = step.say[cur.line];
|
|
158
|
+
const hold = lineHold(text, step.hold);
|
|
159
|
+
const lastLine = cur.line === step.say.length - 1;
|
|
160
|
+
last = { ...cur };
|
|
161
|
+
deps.say(text, fmt(cur), hold);
|
|
162
|
+
cur = { step: cur.step, line: cur.line + 1 };
|
|
163
|
+
deps.changed();
|
|
164
|
+
const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
|
|
165
|
+
wait(hold * 1000 + PACE.fade + gap, id, () => tick(id));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
info,
|
|
170
|
+
script: () => script,
|
|
171
|
+
|
|
172
|
+
// Replace the script, keeping the position (clamped) and the state.
|
|
173
|
+
load(next: Script) {
|
|
174
|
+
const playing = state === "playing";
|
|
175
|
+
cancel();
|
|
176
|
+
script = next;
|
|
177
|
+
if (cur.step > next.steps.length) cur = { step: next.steps.length, line: 0 };
|
|
178
|
+
normalize();
|
|
179
|
+
if (state === "empty") state = "ready";
|
|
180
|
+
if (state === "done" && cur.step < next.steps.length) state = "paused";
|
|
181
|
+
if (playing) {
|
|
182
|
+
entered = true; // screen is already on this step
|
|
183
|
+
tick(run);
|
|
184
|
+
}
|
|
185
|
+
deps.changed();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
resume(): string | null {
|
|
189
|
+
if (!script) return "no_script";
|
|
190
|
+
if (state === "playing") return null;
|
|
191
|
+
if (state === "done") return "done";
|
|
192
|
+
cancel();
|
|
193
|
+
entered = false; // bring the screen back to the current step first
|
|
194
|
+
set("playing");
|
|
195
|
+
tick(run);
|
|
196
|
+
return null;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
pause() {
|
|
200
|
+
if (state !== "playing") return;
|
|
201
|
+
cancel();
|
|
202
|
+
set("paused");
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// Paused by an interruption (raised hand): the next resume replays the
|
|
206
|
+
// last shown line, since the viewer may have missed it.
|
|
207
|
+
rewind() {
|
|
208
|
+
if (state !== "paused" || !last) return;
|
|
209
|
+
cur = { ...last };
|
|
210
|
+
entered = false;
|
|
211
|
+
deps.changed();
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
// Move to a step (and line) and show its screen. Does not play; if it was
|
|
215
|
+
// playing, it keeps playing from there.
|
|
216
|
+
async goto(pos: { step: number; line: number }): Promise<string | null> {
|
|
217
|
+
if (!script) return "no_script";
|
|
218
|
+
const step = script.steps[pos.step];
|
|
219
|
+
if (!step) return "no_such_step";
|
|
220
|
+
if (pos.line > 0 && pos.line >= step.say.length) return "no_such_line";
|
|
221
|
+
const playing = state === "playing";
|
|
222
|
+
cancel();
|
|
223
|
+
cur = { ...pos };
|
|
224
|
+
entered = false;
|
|
225
|
+
if (playing) {
|
|
226
|
+
tick(run);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (state !== "ready") state = "paused";
|
|
230
|
+
if (step.go) await deps.go(step.go);
|
|
231
|
+
entered = true;
|
|
232
|
+
deps.changed();
|
|
233
|
+
return null;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
// Presentation ended: stop where it is.
|
|
237
|
+
stop() {
|
|
238
|
+
if (state === "playing") {
|
|
239
|
+
cancel();
|
|
240
|
+
set("paused");
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/screencast.ts
CHANGED
|
@@ -268,5 +268,75 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
268
268
|
return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
|
|
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
|
+
// 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 };
|
|
272
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
|
+
});
|