agello 0.2.1 → 0.4.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.
@@ -0,0 +1,121 @@
1
+ import { Terminal } from "@xterm/xterm";
2
+ import { FitAddon } from "@xterm/addon-fit";
3
+
4
+ export function mountTerminal(host, status, server) {
5
+ const term = new Terminal({ fontSize: 14, fontFamily: 'Menlo, Consolas, monospace',
6
+ cursorBlink: true, scrollback: 0, disableStdin: true,
7
+ theme: { background: '#101114', foreground: '#e4e4e7' } });
8
+ const fit = new FitAddon();
9
+ term.loadAddon(fit);
10
+ term.open(host);
11
+ fit.fit();
12
+ const ws = new WebSocket(`${server.replace(/^http/, "ws")}/terminal`);
13
+ let active = true, ready = false, failed = false, queued = 0;
14
+ let resizeTimer;
15
+ const send = (msg) => {
16
+ if (active && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
17
+ };
18
+ const dimensions = () => ({ cols: Math.max(2, Math.min(500, term.cols)), rows: Math.max(2, Math.min(300, term.rows)) });
19
+ status.textContent = "터미널 연결 중…";
20
+ ws.onopen = () => send({ type: "terminal.start", ...dimensions() });
21
+ ws.onmessage = (event) => {
22
+ if (!active) return;
23
+ try {
24
+ const frame = JSON.parse(event.data);
25
+ if (frame.type === "terminal.error") {
26
+ failed = true;
27
+ status.textContent = frame.message;
28
+ } else if (frame.type === "terminal.frame") {
29
+ const data = Uint8Array.from(atob(frame.bytes), (c) => c.charCodeAt(0));
30
+ queued += data.length;
31
+ if (queued > 4 * 1024 * 1024) {
32
+ failed = true;
33
+ status.textContent = "화면 처리가 지연되었습니다. 다시 연결해 주세요.";
34
+ ws.close();
35
+ return;
36
+ }
37
+ if (term.cols !== frame.width || term.rows !== frame.height) term.resize(frame.width, frame.height);
38
+ term.write(data, () => { queued -= data.length; });
39
+ if (!ready) {
40
+ ready = true;
41
+ term.options.disableStdin = false;
42
+ status.textContent = "터미널 연결됨 · 키보드 입력 가능";
43
+ term.focus();
44
+ }
45
+ }
46
+ } catch {
47
+ failed = true;
48
+ status.textContent = "터미널 화면을 읽을 수 없습니다. 다시 연결해 주세요.";
49
+ ws.close();
50
+ }
51
+ };
52
+ ws.onclose = () => {
53
+ if (!active) return;
54
+ ready = false;
55
+ term.options.disableStdin = true;
56
+ if (!failed) status.textContent = "연결이 종료되었습니다. 다시 연결해 주세요.";
57
+ };
58
+ ws.onerror = () => { failed = true; status.textContent = "터미널 서버에 연결할 수 없습니다."; };
59
+ term.onData((text) => { if (ready) send({ type: "terminal.input", text }); });
60
+ // Herdr renders viewport frames; scroll the source viewport rather than an
61
+ // xterm scrollback buffer. Herdr routes wheel events to alternate-screen apps.
62
+ const wheel = (event) => {
63
+ if (!ready || !event.deltaY || event.ctrlKey) return;
64
+ event.preventDefault();
65
+ event.stopPropagation();
66
+ send({ type: "terminal.scroll", direction: event.deltaY < 0 ? "up" : "down",
67
+ lines: Math.min(100, Math.max(1, Math.ceil(Math.abs(event.deltaY) / 40))) });
68
+ };
69
+ host.addEventListener("wheel", wheel, { passive: false, capture: true });
70
+ // xterm pastes text only. For images (a Finder file copy or a screenshot),
71
+ // upload the file the browser received and paste its path, as a local
72
+ // terminal does for a dropped file: Claude Code attaches image paths.
73
+ // (Sending Ctrl+V instead would make Claude Code read the system clipboard,
74
+ // which for a Finder copy holds the file icon, not the image.)
75
+ const paste = async (event) => {
76
+ const data = event.clipboardData;
77
+ if (!ready || !data) return;
78
+ const files = [...data.items].filter((i) => i.kind === "file" && i.type.startsWith("image/")).map((i) => i.getAsFile());
79
+ if (!files.length) return;
80
+ event.preventDefault();
81
+ event.stopPropagation();
82
+ status.textContent = "이미지 올리는 중…";
83
+ try {
84
+ const images = await Promise.all(files.slice(0, 5).map((f) => new Promise((resolve, reject) => {
85
+ const r = new FileReader();
86
+ r.onload = () => resolve({ type: f.type, data: String(r.result).split(",")[1] });
87
+ r.onerror = reject;
88
+ r.readAsDataURL(f);
89
+ })));
90
+ const res = await fetch(`${server}/upload`, {
91
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images }),
92
+ });
93
+ const d = await res.json();
94
+ if (!d.ok) throw new Error(d.error);
95
+ send({ type: "terminal.input", text: `\x1b[200~${d.paths.join(" ")}\x1b[201~` });
96
+ status.textContent = "터미널 연결됨 · 키보드 입력 가능";
97
+ } catch {
98
+ status.textContent = "이미지를 올리지 못했습니다. (PNG·JPEG·GIF·WebP, 10MB 이하)";
99
+ }
100
+ };
101
+ host.addEventListener("paste", paste, true);
102
+ const observer = new ResizeObserver(() => {
103
+ clearTimeout(resizeTimer);
104
+ resizeTimer = setTimeout(() => {
105
+ if (!active || !host.clientWidth || !host.clientHeight) return;
106
+ fit.fit();
107
+ if (ready) send({ type: "terminal.resize", ...dimensions() });
108
+ }, 100);
109
+ });
110
+ observer.observe(host);
111
+ return () => {
112
+ active = false;
113
+ clearTimeout(resizeTimer);
114
+ observer.disconnect();
115
+ host.removeEventListener("wheel", wheel, true);
116
+ host.removeEventListener("paste", paste, true);
117
+ ws.close();
118
+ term.dispose();
119
+ host.replaceChildren();
120
+ };
121
+ }
package/src/player.ts DELETED
@@ -1,234 +0,0 @@
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
- }