agello 0.3.0 → 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.
- package/README.md +24 -35
- package/bin/agello.ts +0 -68
- package/package.json +6 -2
- package/src/panes.ts +86 -0
- package/src/screencast.ts +1 -71
- package/src/server.ts +84 -133
- package/src/terminal.ts +122 -0
- package/web/agent-bridge.js +408 -277
- package/web/index.html +12 -0
- package/web/terminal.js +121 -0
- package/src/player.ts +0 -234
package/web/index.html
CHANGED
|
@@ -12,5 +12,17 @@
|
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<agent-bridge screen></agent-bridge>
|
|
15
|
+
<script>
|
|
16
|
+
// Keep the pane picked in the drawer across reloads (?server=http://127.0.0.1:<port>).
|
|
17
|
+
const bridge = document.querySelector("agent-bridge");
|
|
18
|
+
const saved = new URLSearchParams(location.search).get("server");
|
|
19
|
+
if (saved && /^http:\/\/127\.0\.0\.1:\d+$/.test(saved)) bridge.setAttribute("server", saved);
|
|
20
|
+
bridge.addEventListener("agent-pane", (e) => {
|
|
21
|
+
const url = new URL(location.href);
|
|
22
|
+
if (e.detail.server === location.origin) url.searchParams.delete("server");
|
|
23
|
+
else url.searchParams.set("server", e.detail.server);
|
|
24
|
+
history.replaceState(null, "", url);
|
|
25
|
+
});
|
|
26
|
+
</script>
|
|
15
27
|
</body>
|
|
16
28
|
</html>
|
package/web/terminal.js
ADDED
|
@@ -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
|
-
}
|