agello 0.3.0 → 0.4.2
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 -3
- package/package.json +6 -2
- package/src/panes.ts +86 -0
- package/src/server.ts +72 -8
- package/src/terminal.ts +122 -0
- package/web/agent-bridge.js +383 -11
- package/web/index.html +12 -0
- package/web/terminal.js +121 -0
package/README.md
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
- Chat with the agent from a web page; terminal input, browser input and agent replies are shown as separate bubbles
|
|
6
6
|
- Live session status, tool activity, and prompts queued while the agent is busy (same order as the terminal)
|
|
7
7
|
- Conversation saved per pane in the browser, kept across reloads
|
|
8
|
+
- Interactive terminal view of the existing herdr pane: live cursor, keyboard input, and automatic resize
|
|
8
9
|
- Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
|
|
9
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.
|
|
13
14
|
|
|
14
15
|
## Requirements
|
|
15
16
|
|
|
16
17
|
- [Bun](https://bun.sh) >= 1.1
|
|
17
|
-
- [herdr](https://herdr.dev) (the agent must run inside a herdr pane)
|
|
18
|
+
- [herdr](https://herdr.dev) (the agent must run inside a herdr pane); interactive terminal view requires `herdr terminal session control`
|
|
18
19
|
- Optional: `terminal-browser` for the screen panel
|
|
19
20
|
|
|
20
21
|
## Install
|
|
@@ -89,6 +90,20 @@ Selector: nearest unique `id` / `data-testid` ancestor, then `tag:nth-of-type` s
|
|
|
89
90
|
|
|
90
91
|
State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
|
|
91
92
|
|
|
93
|
+
### Pane picker
|
|
94
|
+
|
|
95
|
+
Click the status in the header to open a drawer with herdr's workspace -> tab -> pane tree (the current pane's workspace is expanded; a workspace with a single tab lists its panes directly). Search filters by workspace, title, folder, or agent. Picking a pane switches the page to that pane's server, starting it if needed (`?server=` keeps the choice across reloads). Panes without Claude Code open as a terminal, since chat supports Claude Code only.
|
|
96
|
+
|
|
97
|
+
`+` adds a workspace (top), a tab (workspace row), or splits a pane (pane row); new panes open as a terminal. Nothing can be closed from the page: closing a herdr pane ends its processes.
|
|
98
|
+
|
|
99
|
+
### Interactive terminal
|
|
100
|
+
|
|
101
|
+
Click the terminal icon in the header to replace the chat with the pane's live terminal (the screen panel stays). Keyboard input (including Korean text, arrow keys, and Ctrl+C) goes directly to that terminal. The viewport follows the browser size, and the mouse wheel scrolls through Herdr. Click the icon again or close the page to release control; the pane and its process keep running. If disconnected, use **다시 연결** to restore the current screen.
|
|
102
|
+
|
|
103
|
+
Only one browser connection can control a pane through this server at a time. Agello never forcibly takes over another Herdr controller. While connected, resizing the browser also changes the source terminal size. Terminal input is raw input, without the chat's `[browser]` prefix.
|
|
104
|
+
|
|
105
|
+
The xterm.js renderer and its styles are served locally; no terminal CDN is required. Herdr sends an initial ANSI screen and subsequent frame updates over the WebSocket, including cursor state. Terminal frames are not stored in browser chat history.
|
|
106
|
+
|
|
92
107
|
### Presentation mode
|
|
93
108
|
|
|
94
109
|
`agello present x,y,w,h` (CSS px of the browser's visible viewport; omit for the whole screen):
|
|
@@ -142,11 +157,17 @@ See `web/embed-example.html`.
|
|
|
142
157
|
- **Send**: `herdr agent prompt <pane> "<text>"` (kept to 3 lines so Claude Code does not treat it as a paste)
|
|
143
158
|
- **Status**: `herdr agent get` / `herdr pane get`, polled every 1.5s
|
|
144
159
|
- **Chat**: tails the Claude Code transcript (`$CLAUDE_CONFIG_DIR/projects/*/<session>.jsonl`) and streams it over SSE
|
|
160
|
+
- **Panes**: `GET /panes` (`herdr workspace/tab/pane list`), `POST /panes/connect` (`agello start --pane`), `POST /panes/create` (`herdr workspace create`, `tab create`, `pane split`, always `--no-focus`)
|
|
161
|
+
- **Terminal**: `/terminal` WebSocket relays `herdr terminal session control <pane>` ANSI frames and validated input, resize, and scroll commands to xterm.js
|
|
145
162
|
- **Screen**: finds the terminal-browser CDP port via `terminal-browser ls --json` and relays `Page.startScreencast` frames; user control forwards `Input.*` events only
|
|
146
163
|
|
|
147
164
|
## Security
|
|
148
165
|
|
|
149
|
-
The server listens on `127.0.0.1` only. Data and input endpoints accept requests from the same origin and `localhost` pages only; other origins get `403` unless added with `--allow-origin`.
|
|
166
|
+
The server listens on `127.0.0.1` only. Data and input endpoints accept requests from the same origin and `localhost` pages only; other origins get `403` unless added with `--allow-origin`. Allowed origins can send chat prompts and connect to `/terminal` to type directly into the pane, so keep the allow list short.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
Run `bun install` and `bun test`. Tests cover the pane tree and creation (against a mock herdr), local terminal assets, origin rejection, ANSI frames, keyboard bytes, resize, exclusive control, and reconnect.
|
|
150
171
|
|
|
151
172
|
## License
|
|
152
173
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agello",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -31,5 +31,9 @@
|
|
|
31
31
|
"engines": {
|
|
32
32
|
"bun": ">=1.1"
|
|
33
33
|
},
|
|
34
|
-
"license": "MIT"
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@xterm/addon-fit": "0.11.0",
|
|
37
|
+
"@xterm/xterm": "6.0.0"
|
|
38
|
+
}
|
|
35
39
|
}
|
package/src/panes.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// herdr workspace -> tab -> pane tree, creation, and switching to a pane's own
|
|
2
|
+
// agello server (one server per pane; started on demand through the CLI).
|
|
3
|
+
// Deliberately no close/delete: closing a herdr pane kills its processes.
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { run, runJson } from "./run.ts";
|
|
7
|
+
|
|
8
|
+
const CLI = join(import.meta.dir, "..", "bin", "agello.ts");
|
|
9
|
+
const ID = /^[A-Za-z0-9]+:[A-Za-z0-9]+$/; // herdr ids: w23, w23:t1, w23:p3
|
|
10
|
+
const WS_ID = /^[A-Za-z0-9]+$/;
|
|
11
|
+
|
|
12
|
+
// Resolve herdr on the current PATH (Bun.spawn uses the PATH from process start).
|
|
13
|
+
const bin = () => Bun.which("herdr", { PATH: process.env.PATH }) ?? "herdr";
|
|
14
|
+
const herdr = async (...cmd: string[]) => (await runJson([bin(), ...cmd], 5000)) ?? { error: { code: "herdr_failed" } };
|
|
15
|
+
|
|
16
|
+
export type TreePane = { id: string; agent?: string; status: string; title?: string; cwd?: string; focused: boolean };
|
|
17
|
+
export type TreeTab = { id: string; label: string; number: number; panes: TreePane[] };
|
|
18
|
+
export type TreeWorkspace = { id: string; label: string; number: number; tabs: TreeTab[] };
|
|
19
|
+
|
|
20
|
+
export async function paneTree(): Promise<TreeWorkspace[] | null> {
|
|
21
|
+
const [w, t, p] = await Promise.all([herdr("workspace", "list"), herdr("tab", "list"), herdr("pane", "list")]);
|
|
22
|
+
if (!w.result || !t.result || !p.result) return null;
|
|
23
|
+
const tabs = new Map<string, TreeTab>();
|
|
24
|
+
for (const tab of t.result.tabs)
|
|
25
|
+
tabs.set(tab.tab_id, { id: tab.tab_id, label: tab.label, number: tab.number, panes: [] });
|
|
26
|
+
for (const pane of p.result.panes)
|
|
27
|
+
tabs.get(pane.tab_id)?.panes.push({
|
|
28
|
+
id: pane.pane_id,
|
|
29
|
+
agent: pane.agent,
|
|
30
|
+
status: pane.agent_status,
|
|
31
|
+
title: pane.terminal_title_stripped || undefined,
|
|
32
|
+
cwd: pane.foreground_cwd || pane.cwd,
|
|
33
|
+
focused: pane.focused,
|
|
34
|
+
});
|
|
35
|
+
return w.result.workspaces.map((ws: any) => ({
|
|
36
|
+
id: ws.workspace_id,
|
|
37
|
+
label: ws.label,
|
|
38
|
+
number: ws.number,
|
|
39
|
+
tabs: t.result.tabs
|
|
40
|
+
.filter((tab: any) => tab.workspace_id === ws.workspace_id)
|
|
41
|
+
.map((tab: any) => tabs.get(tab.tab_id)!),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const text = (v: unknown, max = 200) => (typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined);
|
|
46
|
+
const fail = (error: string, status = 400) => Response.json({ ok: false, error }, { status });
|
|
47
|
+
|
|
48
|
+
// POST /panes/connect {pane} -> {ok, url}: the pane's server, started if needed.
|
|
49
|
+
async function connect(pane: unknown): Promise<Response> {
|
|
50
|
+
if (typeof pane !== "string" || !ID.test(pane)) return fail("invalid_pane");
|
|
51
|
+
if (!(await herdr("pane", "get", pane)).result) return fail("pane_not_found", 404);
|
|
52
|
+
const out = await run([process.execPath, CLI, "start", "--pane", pane], 20000);
|
|
53
|
+
const url = out?.match(/https?:\/\/127\.0\.0\.1:\d+/)?.[0];
|
|
54
|
+
return url ? Response.json({ ok: true, pane, url }) : fail("server_start_failed", 502);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// POST /panes/create {kind: workspace|tab|pane, ...} -> {ok, pane}: the new root pane.
|
|
58
|
+
async function create(d: any): Promise<Response> {
|
|
59
|
+
const cwd = text(d.cwd, 1024);
|
|
60
|
+
if (cwd && !cwd.startsWith("/")) return fail("invalid_cwd");
|
|
61
|
+
const label = text(d.label, 80);
|
|
62
|
+
const opt = [...(cwd ? ["--cwd", cwd] : []), ...(label ? ["--label", label] : []), "--no-focus"];
|
|
63
|
+
let res: any;
|
|
64
|
+
if (d.kind === "workspace") res = await herdr("workspace", "create", ...opt);
|
|
65
|
+
else if (d.kind === "tab") {
|
|
66
|
+
if (typeof d.workspace !== "string" || !WS_ID.test(d.workspace)) return fail("invalid_workspace");
|
|
67
|
+
res = await herdr("tab", "create", "--workspace", d.workspace, ...opt);
|
|
68
|
+
} else if (d.kind === "pane") {
|
|
69
|
+
if (typeof d.pane !== "string" || !ID.test(d.pane)) return fail("invalid_pane");
|
|
70
|
+
if (d.direction !== "right" && d.direction !== "down") return fail("invalid_direction");
|
|
71
|
+
res = await herdr("pane", "split", d.pane, "--direction", d.direction, ...(cwd ? ["--cwd", cwd] : []), "--no-focus");
|
|
72
|
+
} else return fail("invalid_kind");
|
|
73
|
+
const pane = res.result?.root_pane?.pane_id ?? res.result?.pane?.pane_id;
|
|
74
|
+
return pane ? Response.json({ ok: true, pane }) : fail(res.error?.code ?? "herdr_failed", 502);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function panesRoute(req: Request, path: string, current: string): Promise<Response | null> {
|
|
78
|
+
if (path === "/panes" && req.method === "GET") {
|
|
79
|
+
const tree = await paneTree();
|
|
80
|
+
return tree ? Response.json({ ok: true, current, workspaces: tree }) : fail("herdr_failed", 502);
|
|
81
|
+
}
|
|
82
|
+
if (req.method !== "POST" || (path !== "/panes/connect" && path !== "/panes/create")) return null;
|
|
83
|
+
const d = (await req.json().catch(() => null)) as any;
|
|
84
|
+
if (!d || typeof d !== "object") return fail("invalid_body");
|
|
85
|
+
return path === "/panes/connect" ? connect(d.pane) : create(d);
|
|
86
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
// GET /agent-bridge.js <agent-bridge> web component
|
|
5
5
|
// GET /status session liveness (herdr agent get + pane get)
|
|
6
6
|
// GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
|
|
7
|
-
// POST /send {action, text} -> herdr agent prompt
|
|
7
|
+
// POST /send {action, text, images?: [{type, data(base64)}]} -> herdr agent prompt
|
|
8
|
+
// (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
|
|
8
9
|
// (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
|
|
9
10
|
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
11
|
+
// POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
|
|
12
|
+
// WS /terminal interactive herdr terminal frames, keyboard input, resize
|
|
13
|
+
// GET /panes herdr workspace -> tab -> pane tree
|
|
14
|
+
// POST /panes/connect {pane} -> url of that pane's server (started if needed)
|
|
15
|
+
// POST /panes/create {kind: workspace|tab|pane, cwd?, label?, workspace?, pane?, direction?}
|
|
10
16
|
// WS /input user control: CDP Input.* commands forwarded to the page;
|
|
11
17
|
// {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
|
|
12
18
|
// GET /present presentation state
|
|
@@ -17,7 +23,10 @@
|
|
|
17
23
|
// POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
|
|
18
24
|
// POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
|
|
19
25
|
|
|
20
|
-
import {
|
|
26
|
+
import { createTerminal, type SocketData } from "./terminal.ts";
|
|
27
|
+
import { panesRoute } from "./panes.ts";
|
|
28
|
+
import { mkdir } from "node:fs/promises";
|
|
29
|
+
import { homedir, tmpdir } from "node:os";
|
|
21
30
|
import { join } from "node:path";
|
|
22
31
|
import { runJson } from "./run.ts";
|
|
23
32
|
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
@@ -32,6 +41,30 @@ export type ServerOptions = {
|
|
|
32
41
|
};
|
|
33
42
|
|
|
34
43
|
export const BROWSER_PREFIX = "[browser]";
|
|
44
|
+
const UPLOAD_DIR = join(tmpdir(), "agello-uploads");
|
|
45
|
+
const IMAGE_EXT: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
|
|
46
|
+
const MAX_IMAGES = 5;
|
|
47
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
// Save pasted/dropped chat images so the agent can open them by path
|
|
50
|
+
// (herdr agent prompt carries text only). null: invalid input.
|
|
51
|
+
export async function saveImages(list: unknown): Promise<string[] | null> {
|
|
52
|
+
if (list === undefined) return [];
|
|
53
|
+
if (!Array.isArray(list) || list.length > MAX_IMAGES) return null;
|
|
54
|
+
const files = list.map((img: any) => {
|
|
55
|
+
const ext = IMAGE_EXT[img?.type];
|
|
56
|
+
if (!ext || typeof img.data !== "string") return null;
|
|
57
|
+
const bytes = Buffer.from(img.data, "base64");
|
|
58
|
+
return bytes.length && bytes.length <= MAX_IMAGE_BYTES ? { ext, bytes } : null;
|
|
59
|
+
});
|
|
60
|
+
if (files.includes(null)) return null;
|
|
61
|
+
await mkdir(UPLOAD_DIR, { recursive: true, mode: 0o700 });
|
|
62
|
+
return Promise.all(files.map(async (f) => {
|
|
63
|
+
const path = join(UPLOAD_DIR, `${crypto.randomUUID()}.${f!.ext}`);
|
|
64
|
+
await Bun.write(path, f!.bytes);
|
|
65
|
+
return path;
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
35
68
|
const WEB_DIR = join(import.meta.dir, "..", "web");
|
|
36
69
|
|
|
37
70
|
// ---------- pure helpers ----------
|
|
@@ -125,6 +158,12 @@ export async function startServer(opts: ServerOptions) {
|
|
|
125
158
|
const HTML = Bun.file(join(WEB_DIR, "index.html"));
|
|
126
159
|
const COMPONENT = Bun.file(join(WEB_DIR, "agent-bridge.js"));
|
|
127
160
|
|
|
161
|
+
const terminal = createTerminal(PANE);
|
|
162
|
+
const bundle = await Bun.build({ entrypoints: [join(WEB_DIR, "terminal.js")], target: "browser", minify: true });
|
|
163
|
+
if (!bundle.success) throw new Error(`Terminal bundle failed: ${bundle.logs.join("\n")}`);
|
|
164
|
+
const terminalJS = await bundle.outputs[0].text();
|
|
165
|
+
const terminalCSS = Bun.file(new URL(import.meta.resolve("@xterm/xterm/css/xterm.css")));
|
|
166
|
+
|
|
128
167
|
const agentInfo = async (): Promise<any | null> =>
|
|
129
168
|
(await herdr(3000, "agent", "get", PANE))?.result?.agent ?? null;
|
|
130
169
|
|
|
@@ -324,7 +363,8 @@ export async function startServer(opts: ServerOptions) {
|
|
|
324
363
|
|
|
325
364
|
const screen = createScreencast({
|
|
326
365
|
browserKey: opts.browser,
|
|
327
|
-
|
|
366
|
+
// shell panes have no agent: fall back to the pane's own tab
|
|
367
|
+
herdrTab: async () => (await agentInfo())?.tab_id ?? (await herdr(3000, "pane", "get", PANE))?.result?.pane?.tab_id,
|
|
328
368
|
});
|
|
329
369
|
|
|
330
370
|
// ----- presentation mode -----
|
|
@@ -482,7 +522,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
482
522
|
}
|
|
483
523
|
|
|
484
524
|
async function sendPrompt(req: Request): Promise<Response> {
|
|
485
|
-
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; target?: Inspected };
|
|
525
|
+
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown; target?: Inspected };
|
|
486
526
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
487
527
|
let text = data.text ?? "";
|
|
488
528
|
if (action === "annotate") {
|
|
@@ -497,11 +537,18 @@ export async function startServer(opts: ServerOptions) {
|
|
|
497
537
|
const at = where();
|
|
498
538
|
if (at) text += ` (대본 정지: ${at})`;
|
|
499
539
|
}
|
|
540
|
+
const images = await saveImages(data.images);
|
|
541
|
+
if (!images) return Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
|
|
542
|
+
text = [text, ...images.map((p) => `[image: ${p}]`)].join("\n");
|
|
500
543
|
const r = await promptAgent(action, text);
|
|
501
544
|
return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
|
|
502
545
|
}
|
|
503
546
|
|
|
504
547
|
async function route(req: Request, url: URL): Promise<Response> {
|
|
548
|
+
if (url.pathname.startsWith("/panes")) {
|
|
549
|
+
const res = await panesRoute(req, url.pathname, PANE);
|
|
550
|
+
if (res) return res;
|
|
551
|
+
}
|
|
505
552
|
switch (url.pathname) {
|
|
506
553
|
case "/status":
|
|
507
554
|
return Response.json({
|
|
@@ -514,6 +561,10 @@ export async function startServer(opts: ServerOptions) {
|
|
|
514
561
|
return events();
|
|
515
562
|
case "/screen":
|
|
516
563
|
return screen.handle();
|
|
564
|
+
case "/terminal.js":
|
|
565
|
+
return new Response(terminalJS, { headers: { "Content-Type": "text/javascript; charset=utf-8" } });
|
|
566
|
+
case "/terminal.css":
|
|
567
|
+
return new Response(terminalCSS, { headers: { "Content-Type": "text/css; charset=utf-8" } });
|
|
517
568
|
case "/agent-bridge.js":
|
|
518
569
|
return new Response(COMPONENT, {
|
|
519
570
|
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
|
|
@@ -521,6 +572,13 @@ export async function startServer(opts: ServerOptions) {
|
|
|
521
572
|
case "/send":
|
|
522
573
|
if (req.method === "POST") return sendPrompt(req);
|
|
523
574
|
break;
|
|
575
|
+
case "/upload":
|
|
576
|
+
if (req.method === "POST") {
|
|
577
|
+
const data = (await req.json().catch(() => ({}))) as { images?: unknown };
|
|
578
|
+
const paths = data.images === undefined ? null : await saveImages(data.images);
|
|
579
|
+
return paths ? Response.json({ ok: true, paths }) : Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
|
|
580
|
+
}
|
|
581
|
+
break;
|
|
524
582
|
case "/present":
|
|
525
583
|
return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
|
|
526
584
|
case "/script":
|
|
@@ -532,13 +590,16 @@ export async function startServer(opts: ServerOptions) {
|
|
|
532
590
|
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
533
591
|
}
|
|
534
592
|
|
|
535
|
-
const server = Bun.serve({
|
|
593
|
+
const server = Bun.serve<SocketData>({
|
|
536
594
|
hostname: "127.0.0.1",
|
|
537
595
|
port: opts.port,
|
|
538
596
|
idleTimeout: 0, // keep SSE open
|
|
539
597
|
websocket: {
|
|
598
|
+
maxPayloadLength: 128 * 1024,
|
|
599
|
+
open(ws) { if (ws.data.kind === "terminal") terminal.open(ws); },
|
|
540
600
|
async message(ws, msg) {
|
|
541
601
|
const raw = String(msg);
|
|
602
|
+
if (ws.data.kind === "terminal") return terminal.message(ws, raw);
|
|
542
603
|
let m: any;
|
|
543
604
|
try {
|
|
544
605
|
m = JSON.parse(raw);
|
|
@@ -556,11 +617,12 @@ export async function startServer(opts: ServerOptions) {
|
|
|
556
617
|
}
|
|
557
618
|
screen.input(raw);
|
|
558
619
|
},
|
|
620
|
+
close(ws) { if (ws.data.kind === "terminal") terminal.close(ws); },
|
|
559
621
|
},
|
|
560
622
|
async fetch(req, server) {
|
|
561
623
|
const url = new URL(req.url);
|
|
562
624
|
const origin = req.headers.get("Origin");
|
|
563
|
-
const isScript =
|
|
625
|
+
const isScript = ["/agent-bridge.js", "/terminal.js", "/terminal.css"].includes(url.pathname);
|
|
564
626
|
|
|
565
627
|
// The component script itself is public; data and input endpoints are origin-checked.
|
|
566
628
|
if (!isScript && !originAllowed(origin, url.origin))
|
|
@@ -579,8 +641,9 @@ export async function startServer(opts: ServerOptions) {
|
|
|
579
641
|
origin,
|
|
580
642
|
);
|
|
581
643
|
|
|
582
|
-
if (url.pathname === "/input")
|
|
583
|
-
return server.upgrade(req
|
|
644
|
+
if (url.pathname === "/input" || url.pathname === "/terminal")
|
|
645
|
+
return server.upgrade(req, { data: { kind: url.pathname === "/terminal" ? "terminal" : "screen" } })
|
|
646
|
+
? undefined : new Response("websocket required", { status: 426 });
|
|
584
647
|
|
|
585
648
|
const res = await route(req, url);
|
|
586
649
|
return isScript ? (res.headers.set("Access-Control-Allow-Origin", "*"), res) : withCors(res, origin);
|
|
@@ -593,6 +656,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
593
656
|
session: SESSION,
|
|
594
657
|
transcript,
|
|
595
658
|
stop() {
|
|
659
|
+
terminal.stop();
|
|
596
660
|
timers.forEach(clearInterval);
|
|
597
661
|
server.stop(true);
|
|
598
662
|
},
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
export type SocketData = { kind: "screen" | "terminal" };
|
|
4
|
+
type Socket = ServerWebSocket<SocketData>;
|
|
5
|
+
const MAX_MESSAGE = 128 * 1024;
|
|
6
|
+
|
|
7
|
+
// The CLI owns Herdr's socket handshake and emits ordered ANSI frame envelopes.
|
|
8
|
+
// Never use --takeover: another controller must release its session first.
|
|
9
|
+
export function createTerminal(pane: string) {
|
|
10
|
+
let owner: Socket | null = null;
|
|
11
|
+
let childProcess: ReturnType<typeof Bun.spawn> | null = null;
|
|
12
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
|
|
14
|
+
function close(ws: Socket) {
|
|
15
|
+
if (owner !== ws) return;
|
|
16
|
+
owner = null;
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
const child = childProcess;
|
|
19
|
+
childProcess = null;
|
|
20
|
+
if (child) {
|
|
21
|
+
try { (child.stdin as any).end(); } catch {}
|
|
22
|
+
child.kill(); // Disconnect only; Herdr keeps the underlying pane alive.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fail(ws: Socket, message: string) {
|
|
27
|
+
ws.send(JSON.stringify({ type: "terminal.error", message }));
|
|
28
|
+
close(ws);
|
|
29
|
+
ws.close(1011, "Terminal disconnected");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function send(ws: Socket, frame: unknown) {
|
|
33
|
+
if (owner !== ws) return;
|
|
34
|
+
if (ws.getBufferedAmount() > 4 * 1024 * 1024) {
|
|
35
|
+
fail(ws, "연결이 느려 터미널을 닫았습니다. 다시 연결해 주세요.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ws.send(JSON.stringify(frame));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function pump(ws: Socket, child: ReturnType<typeof Bun.spawn>) {
|
|
42
|
+
let pending = "";
|
|
43
|
+
let stderr = "";
|
|
44
|
+
let received = false;
|
|
45
|
+
const readError = (async () => {
|
|
46
|
+
for await (const chunk of child.stderr as ReadableStream<Uint8Array>) {
|
|
47
|
+
stderr = (stderr + new TextDecoder().decode(chunk)).slice(-2000);
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
try {
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
for await (const chunk of child.stdout as ReadableStream<Uint8Array>) {
|
|
53
|
+
if (owner !== ws) break;
|
|
54
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
55
|
+
if (pending.length > 8 * 1024 * 1024) throw new Error("Terminal frame too large");
|
|
56
|
+
let end: number;
|
|
57
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
58
|
+
const line = pending.slice(0, end);
|
|
59
|
+
pending = pending.slice(end + 1);
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
const frame = JSON.parse(line);
|
|
62
|
+
if (frame.type === "terminal.frame" && frame.encoding === "ansi") {
|
|
63
|
+
if (!received) { received = true; clearTimeout(timer); }
|
|
64
|
+
send(ws, frame);
|
|
65
|
+
} else if (frame.type === "terminal.closed") {
|
|
66
|
+
fail(ws, frame.reason || "터미널 연결이 종료되었습니다.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await child.exited;
|
|
71
|
+
await readError;
|
|
72
|
+
if (owner === ws) fail(ws, stderr.trim() || "터미널 연결이 종료되었습니다. 다시 연결해 주세요.");
|
|
73
|
+
} catch {
|
|
74
|
+
if (owner === ws) fail(ws, "터미널 스트림을 읽을 수 없습니다.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
open(ws: Socket) {
|
|
80
|
+
if (owner) {
|
|
81
|
+
ws.send(JSON.stringify({ type: "terminal.error", message: "다른 브라우저가 터미널을 사용 중입니다." }));
|
|
82
|
+
ws.close(1008, "Terminal busy");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
owner = ws;
|
|
86
|
+
timer = setTimeout(() => fail(ws, "터미널 연결 시간이 초과되었습니다."), 10000);
|
|
87
|
+
},
|
|
88
|
+
message(ws: Socket, raw: string) {
|
|
89
|
+
if (owner !== ws) return;
|
|
90
|
+
try {
|
|
91
|
+
if (raw.length > MAX_MESSAGE) throw new Error("Message too large");
|
|
92
|
+
const msg = JSON.parse(raw);
|
|
93
|
+
const size = () => Number.isInteger(msg.cols) && Number.isInteger(msg.rows)
|
|
94
|
+
&& msg.cols >= 2 && msg.cols <= 500 && msg.rows >= 2 && msg.rows <= 300;
|
|
95
|
+
if (msg.type === "terminal.start" && !childProcess && size()) {
|
|
96
|
+
childProcess = Bun.spawn([Bun.which("herdr", { PATH: process.env.PATH }) ?? "herdr", "terminal", "session", "control", pane,
|
|
97
|
+
"--cols", String(msg.cols), "--rows", String(msg.rows)],
|
|
98
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
99
|
+
void pump(ws, childProcess);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!childProcess) throw new Error("Terminal not started");
|
|
103
|
+
let command: object;
|
|
104
|
+
if (msg.type === "terminal.input" && typeof msg.text === "string" && msg.text.length <= 65536) {
|
|
105
|
+
command = { type: msg.type, text: msg.text };
|
|
106
|
+
} else if (msg.type === "terminal.resize" && size()) {
|
|
107
|
+
command = { type: msg.type, cols: msg.cols, rows: msg.rows };
|
|
108
|
+
} else if (msg.type === "terminal.scroll" && ["up", "down"].includes(msg.direction)
|
|
109
|
+
&& Number.isInteger(msg.lines) && msg.lines > 0 && msg.lines <= 100) {
|
|
110
|
+
command = { type: msg.type, direction: msg.direction, lines: msg.lines };
|
|
111
|
+
} else throw new Error("Invalid terminal message");
|
|
112
|
+
const input = childProcess.stdin as Bun.FileSink;
|
|
113
|
+
if (input.write(JSON.stringify(command) + "\n") > 1024 * 1024) throw new Error("Input overflow");
|
|
114
|
+
input.flush();
|
|
115
|
+
} catch {
|
|
116
|
+
fail(ws, "터미널 요청을 처리할 수 없습니다.");
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
close,
|
|
120
|
+
stop() { if (owner) close(owner); },
|
|
121
|
+
};
|
|
122
|
+
}
|
package/web/agent-bridge.js
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
// (tool calls are also kept in the conversation as one-line rows, like the terminal)
|
|
28
28
|
// agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
|
|
29
29
|
// agent-present detail: { on, rect?: {x,y,w,h}, since? }
|
|
30
|
+
// agent-pane detail: { pane, server } another pane was picked in the drawer (server attribute changed)
|
|
31
|
+
//
|
|
32
|
+
// Pane picker
|
|
33
|
+
// Clicking the status in the header opens a drawer with herdr's workspace -> tab -> pane tree.
|
|
34
|
+
// Picking a pane switches to that pane's own server (started on demand). Workspaces, tabs and
|
|
35
|
+
// panes can be added there; nothing is closed from the page (closing kills the pane's processes).
|
|
30
36
|
//
|
|
31
37
|
// Presentation mode (started by the agent: `agello present [x,y,w,h]`)
|
|
32
38
|
// The screen panel expands to fill the window and shows only the given
|
|
@@ -44,7 +50,7 @@
|
|
|
44
50
|
// is working (e.g. answering a question), a small loader is shown.
|
|
45
51
|
//
|
|
46
52
|
// Methods
|
|
47
|
-
// el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra:
|
|
53
|
+
// el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: {images: [{type, data (base64)}]}, {target} for annotate)
|
|
48
54
|
// el.clearHistory() clear saved chat for the current pane
|
|
49
55
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
50
56
|
|
|
@@ -79,6 +85,57 @@ const ACTIONS = {
|
|
|
79
85
|
};
|
|
80
86
|
|
|
81
87
|
const CSS = `
|
|
88
|
+
.pane-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px; border-radius: 999px; font-size: 12px;
|
|
89
|
+
color: var(--_muted); max-width: 46cqi; }
|
|
90
|
+
.pane-btn .state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
91
|
+
.pane-btn .dot { flex: none; }
|
|
92
|
+
.drawer-backdrop { display: none; position: absolute; inset: 0; z-index: 20; background: rgb(0 0 0 / .25); }
|
|
93
|
+
.drawer { position: absolute; top: 0; right: 0; bottom: 0; z-index: 21; width: min(360px, 92%); display: flex;
|
|
94
|
+
flex-direction: column; background: var(--_panel); border-left: 1px solid var(--_line);
|
|
95
|
+
box-shadow: -8px 0 24px rgb(0 0 0 / .18); transform: translateX(100%); visibility: hidden;
|
|
96
|
+
transition: transform .18s ease, visibility 0s .18s; }
|
|
97
|
+
:host([panes-open]) .drawer { transform: none; visibility: visible; transition: transform .18s ease; }
|
|
98
|
+
:host([panes-open]) .drawer-backdrop { display: block; }
|
|
99
|
+
.drawer-head { display: flex; gap: 6px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--_line); }
|
|
100
|
+
.drawer-head strong { font-size: 13px; margin-right: auto; }
|
|
101
|
+
.icon-btn { padding: 1px 8px; border-radius: 6px; font-size: 14px; line-height: 1.5; }
|
|
102
|
+
.pane-filter { width: 100%; padding: 5px 9px; font: inherit; font-size: 12px; border: 1px solid var(--_line);
|
|
103
|
+
border-radius: 7px; background: var(--_bg); color: var(--_text); }
|
|
104
|
+
.drawer-search { padding: 8px 12px; border-bottom: 1px solid var(--_line); }
|
|
105
|
+
.add-form { display: grid; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--_line); font-size: 12px; }
|
|
106
|
+
.add-form[hidden] { display: none; }
|
|
107
|
+
.add-form .add-title { font-weight: 600; }
|
|
108
|
+
.add-form input, .add-form select { padding: 4px 8px; font: inherit; border: 1px solid var(--_line); border-radius: 6px;
|
|
109
|
+
background: var(--_bg); color: var(--_text); }
|
|
110
|
+
.add-form .add-row { display: flex; gap: 6px; justify-content: flex-end; }
|
|
111
|
+
.tree { flex: 1; overflow: auto; padding: 6px 0; font-size: 13px; }
|
|
112
|
+
.tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 10px; cursor: pointer; min-width: 0; }
|
|
113
|
+
.tree-row:hover { background: var(--_user); }
|
|
114
|
+
.tree-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
115
|
+
.tree-row .meta { font-size: 11px; color: var(--_muted); flex: none; }
|
|
116
|
+
.tree-row .caret { width: 12px; flex: none; color: var(--_muted); font-size: 10px; }
|
|
117
|
+
.tree-row .row-add { visibility: hidden; padding: 0 6px; font-size: 12px; border-radius: 5px; flex: none; }
|
|
118
|
+
.tree-row:hover .row-add, .tree-row:focus-within .row-add { visibility: visible; }
|
|
119
|
+
.tree-row.ws .name { font-weight: 600; }
|
|
120
|
+
.tree-row.tab { padding-left: 26px; color: var(--_muted); }
|
|
121
|
+
.tree-row.pane { padding-left: 30px; }
|
|
122
|
+
.tree-row.pane.nested { padding-left: 46px; }
|
|
123
|
+
.tree-row.pane.current { background: color-mix(in srgb, var(--_accent) 16%, transparent); }
|
|
124
|
+
.tree-row .badge { font-size: 10px; padding: 0 6px; border-radius: 999px; border: 1px solid var(--_line); color: var(--_muted); flex: none; }
|
|
125
|
+
.tree-empty, .drawer-hint { padding: 8px 12px; font-size: 12px; color: var(--_muted); }
|
|
126
|
+
.drawer-hint:empty { display: none; }
|
|
127
|
+
.term-btn { display: inline-flex; align-items: center; justify-content: center; padding: 3px 7px; border-radius: 999px; }
|
|
128
|
+
.term-btn svg { width: 14px; height: 14px; }
|
|
129
|
+
.term-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: white; }
|
|
130
|
+
.terminal-panel { display: none; flex: 1; min-height: 0; flex-direction: column; background: #101114; }
|
|
131
|
+
:host([terminal-open]) .chat > .stage, :host([terminal-open]) .chat > footer { display: none; }
|
|
132
|
+
:host([terminal-open]) .terminal-panel { display: flex; }
|
|
133
|
+
.terminal-bar { display: flex; gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid #26272b; }
|
|
134
|
+
.terminal-status { flex: 1; font-size: 12px; color: #a1a1aa; overflow-wrap: anywhere; }
|
|
135
|
+
.terminal-retry { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
|
|
136
|
+
.terminal-host { flex: 1; min-height: 0; padding: 8px 12px; overflow: hidden; }
|
|
137
|
+
.terminal-host textarea { min-height: 0; border: 0; padding: 0; resize: none; }
|
|
138
|
+
|
|
82
139
|
:host {
|
|
83
140
|
--_bg: var(--ab-bg, #f6f6f4); --_panel: var(--ab-panel, #fff); --_text: var(--ab-text, #1f1f1f);
|
|
84
141
|
--_muted: var(--ab-muted, #6b6b6b); --_line: var(--ab-line, #e4e4e0); --_accent: var(--ab-accent, #2f6fed);
|
|
@@ -103,7 +160,6 @@ header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; bord
|
|
|
103
160
|
background: var(--_panel); }
|
|
104
161
|
:host([no-header]) header { display: none; }
|
|
105
162
|
h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
106
|
-
.clear-btn { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
|
|
107
163
|
.session { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--_muted); }
|
|
108
164
|
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--_muted); }
|
|
109
165
|
.dot.idle, .dot.done { background: var(--_ok); } .dot.working { background: var(--_busy); }
|
|
@@ -341,18 +397,38 @@ button.primary { background: var(--_accent); border-color: var(--_accent); color
|
|
|
341
397
|
button:disabled { cursor: not-allowed; opacity: .45; }
|
|
342
398
|
:host([no-actions]) .secondary { display: none; }
|
|
343
399
|
.hint { font-size: 12px; color: var(--_muted); }
|
|
400
|
+
.attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
401
|
+
.attachments:empty { display: none; }
|
|
402
|
+
.attachment { position: relative; width: 64px; height: 64px; border: 1px solid var(--_line); border-radius: 8px; overflow: hidden; }
|
|
403
|
+
.attachment img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
404
|
+
.attachment button { position: absolute; top: 2px; right: 2px; padding: 0 6px; font-size: 12px; line-height: 1.5;
|
|
405
|
+
border-radius: 999px; background: rgb(0 0 0 / .6); border: 0; color: #fff; }
|
|
406
|
+
.composer.dragover textarea { outline: 2px dashed var(--_accent); outline-offset: -1px; }
|
|
344
407
|
`;
|
|
345
408
|
|
|
346
409
|
const TEMPLATE = `
|
|
347
|
-
<header><h1 part="heading"></h1><div class="session"><button class="
|
|
410
|
+
<header><h1 part="heading"></h1><div class="session"><button class="term-btn" aria-pressed="false" title="터미널에서 실행 중인 세션 보기 (다시 누르면 채팅)" aria-label="터미널"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><path d="M4.5 6l2 2-2 2M8.5 10.5h3"/></svg></button><button class="pane-btn" aria-haspopup="dialog" aria-expanded="false" title="pane 선택"><span class="dot"></span><span class="state">연결 중</span><span aria-hidden="true">▾</span></button></div></header>
|
|
411
|
+
<div class="drawer-backdrop"></div>
|
|
412
|
+
<aside class="drawer" role="dialog" aria-label="pane 선택">
|
|
413
|
+
<div class="drawer-head"><strong>pane 선택</strong><button class="icon-btn ws-add" title="workspace 추가" aria-label="workspace 추가">+</button><button class="icon-btn panes-refresh" title="새로고침" aria-label="새로고침">↻</button><button class="icon-btn drawer-close" title="닫기 (Esc)" aria-label="닫기">×</button></div>
|
|
414
|
+
<div class="drawer-search"><input class="pane-filter" type="search" placeholder="workspace, 제목, 폴더 검색" aria-label="pane 검색"></div>
|
|
415
|
+
<form class="add-form" hidden><span class="add-title"></span>
|
|
416
|
+
<input name="label" placeholder="이름 (선택)" maxlength="80">
|
|
417
|
+
<input name="cwd" placeholder="폴더 경로 (선택, 절대 경로)">
|
|
418
|
+
<select name="direction" aria-label="분할 방향"><option value="right">오른쪽으로 분할</option><option value="down">아래로 분할</option></select>
|
|
419
|
+
<div class="add-row"><button type="button" class="add-cancel">취소</button><button type="submit" class="primary">추가</button></div></form>
|
|
420
|
+
<div class="tree" role="tree"></div><div class="drawer-hint" role="status"></div>
|
|
421
|
+
</aside>
|
|
348
422
|
<div class="body"><section class="chat">
|
|
423
|
+
<div class="terminal-panel" aria-label="터미널"><div class="terminal-bar"><span class="terminal-status" role="status"></span><button class="terminal-retry">다시 연결</button></div><div class="terminal-host"></div></div>
|
|
349
424
|
<div class="stage">
|
|
350
425
|
<div class="log" part="log"><div class="inner"><div class="empty">이 pane의 대화가 브라우저에 저장돼요.</div></div><div class="inner queue" part="queue"></div></div>
|
|
351
426
|
<div class="activity" part="activity"></div>
|
|
352
427
|
</div>
|
|
353
428
|
<footer part="composer"><div class="composer">
|
|
354
429
|
<div class="grip" role="separator" aria-orientation="horizontal" aria-label="입력창 높이 조절 (위아래 방향키)" tabindex="0" title="끌어서 입력창 높이 조절"></div>
|
|
355
|
-
<textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter
|
|
430
|
+
<textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈, 이미지 붙여넣기·끌어놓기)"></textarea>
|
|
431
|
+
<div class="attachments" aria-label="첨부 이미지"></div>
|
|
356
432
|
<div class="row">
|
|
357
433
|
<button class="secondary" data-action="approve">승인</button>
|
|
358
434
|
<button class="secondary" data-action="reject">거절</button>
|
|
@@ -396,6 +472,15 @@ class AgentBridge extends HTMLElement {
|
|
|
396
472
|
#sel = null; // selected element (annotate)
|
|
397
473
|
#inspectId = 0;
|
|
398
474
|
#inspectWait = new Map();
|
|
475
|
+
#terminalCleanup = null;
|
|
476
|
+
#terminalVersion = 0;
|
|
477
|
+
#terminalStyles = null;
|
|
478
|
+
#panes = null; // {current, workspaces}
|
|
479
|
+
#expanded = new Set();
|
|
480
|
+
#adding = null; // {kind, workspace?, pane?}
|
|
481
|
+
#images = []; // chat attachments: {type, data (base64), url (preview)}
|
|
482
|
+
static IMAGE_MAX = 5;
|
|
483
|
+
static IMAGE_BYTES = 10 * 1024 * 1024;
|
|
399
484
|
#frame = { w: 0, h: 0 };
|
|
400
485
|
#moveQueued = null;
|
|
401
486
|
#status = null;
|
|
@@ -417,7 +502,7 @@ class AgentBridge extends HTMLElement {
|
|
|
417
502
|
activity: q(".activity"), box: q("textarea"), hint: q(".hint"),
|
|
418
503
|
buttons: this.#root.querySelectorAll("button[data-action]"),
|
|
419
504
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
420
|
-
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
505
|
+
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
421
506
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
422
507
|
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
423
508
|
annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
@@ -435,7 +520,10 @@ class AgentBridge extends HTMLElement {
|
|
|
435
520
|
this.#bindControl();
|
|
436
521
|
this.#bindAnnotate();
|
|
437
522
|
this.#bindHand();
|
|
438
|
-
|
|
523
|
+
q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
|
|
524
|
+
q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
|
|
525
|
+
this.#bindPanes();
|
|
526
|
+
this.#bindImages();
|
|
439
527
|
}
|
|
440
528
|
|
|
441
529
|
get server() {
|
|
@@ -448,6 +536,7 @@ class AgentBridge extends HTMLElement {
|
|
|
448
536
|
this.#syncScreen();
|
|
449
537
|
}
|
|
450
538
|
disconnectedCallback() {
|
|
539
|
+
this.#showTerminal(false);
|
|
451
540
|
this.#es?.close();
|
|
452
541
|
this.#es = null;
|
|
453
542
|
this.#screenEs?.close();
|
|
@@ -457,10 +546,286 @@ class AgentBridge extends HTMLElement {
|
|
|
457
546
|
}
|
|
458
547
|
attributeChangedCallback(name) {
|
|
459
548
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
460
|
-
if (name === "server" && this.isConnected) { this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
549
|
+
if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
461
550
|
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
462
551
|
}
|
|
463
552
|
|
|
553
|
+
async #showTerminal(on) {
|
|
554
|
+
const version = ++this.#terminalVersion;
|
|
555
|
+
this.#terminalCleanup?.();
|
|
556
|
+
this.#terminalCleanup = null;
|
|
557
|
+
this.toggleAttribute("terminal-open", on);
|
|
558
|
+
const q = (s) => this.#root.querySelector(s);
|
|
559
|
+
q(".term-btn").setAttribute("aria-pressed", String(on));
|
|
560
|
+
if (!on) return;
|
|
561
|
+
this.#setControl(false);
|
|
562
|
+
this.#setAnnotate(false);
|
|
563
|
+
q(".terminal-status").textContent = "터미널 로딩 중…";
|
|
564
|
+
try {
|
|
565
|
+
if (!this.#terminalStyles) {
|
|
566
|
+
const link = document.createElement("link");
|
|
567
|
+
link.rel = "stylesheet";
|
|
568
|
+
link.href = `${SCRIPT_ORIGIN}/terminal.css`;
|
|
569
|
+
link.dataset.terminalCss = "";
|
|
570
|
+
this.#terminalStyles = new Promise((resolve, reject) => {
|
|
571
|
+
link.onload = resolve;
|
|
572
|
+
link.onerror = () => { this.#terminalStyles = null; link.remove(); reject(new Error("Stylesheet failed")); };
|
|
573
|
+
});
|
|
574
|
+
this.#root.append(link);
|
|
575
|
+
}
|
|
576
|
+
await this.#terminalStyles;
|
|
577
|
+
const { mountTerminal } = await import(`${SCRIPT_ORIGIN}/terminal.js`);
|
|
578
|
+
if (version !== this.#terminalVersion || !this.isConnected) return;
|
|
579
|
+
this.#terminalCleanup = mountTerminal(q(".terminal-host"), q(".terminal-status"), this.server);
|
|
580
|
+
} catch {
|
|
581
|
+
if (version === this.#terminalVersion) q(".terminal-status").textContent = "터미널을 불러오지 못했습니다. 다시 연결해 주세요.";
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------- pane picker (herdr workspace -> tab -> pane) ----------
|
|
586
|
+
|
|
587
|
+
#bindPanes() {
|
|
588
|
+
const q = (s) => this.#root.querySelector(s);
|
|
589
|
+
q(".pane-btn").addEventListener("click", () => this.#openPanes(!this.hasAttribute("panes-open")));
|
|
590
|
+
q(".drawer-close").addEventListener("click", () => this.#openPanes(false));
|
|
591
|
+
q(".drawer-backdrop").addEventListener("click", () => this.#openPanes(false));
|
|
592
|
+
q(".panes-refresh").addEventListener("click", () => this.#loadPanes());
|
|
593
|
+
q(".ws-add").addEventListener("click", () => this.#startAdd({ kind: "workspace" }));
|
|
594
|
+
q(".pane-filter").addEventListener("input", () => this.#renderPanes());
|
|
595
|
+
q(".add-cancel").addEventListener("click", () => this.#startAdd(null));
|
|
596
|
+
q(".add-form").addEventListener("submit", (e) => { e.preventDefault(); this.#submitAdd(); });
|
|
597
|
+
q(".drawer").addEventListener("keydown", (e) => {
|
|
598
|
+
if (e.key === "Escape") { e.stopPropagation(); this.#adding ? this.#startAdd(null) : this.#openPanes(false); }
|
|
599
|
+
});
|
|
600
|
+
q(".tree").addEventListener("click", (e) => {
|
|
601
|
+
const add = e.target.closest(".row-add");
|
|
602
|
+
const row = e.target.closest(".tree-row");
|
|
603
|
+
if (!row) return;
|
|
604
|
+
const { kind, id, ws } = row.dataset;
|
|
605
|
+
if (add) return this.#startAdd(kind === "ws" ? { kind: "tab", workspace: id } : { kind: "pane", pane: id, workspace: ws });
|
|
606
|
+
if (kind === "pane") return this.#selectPane(id);
|
|
607
|
+
this.#expanded.has(id) ? this.#expanded.delete(id) : this.#expanded.add(id);
|
|
608
|
+
this.#renderPanes();
|
|
609
|
+
});
|
|
610
|
+
q(".tree").addEventListener("keydown", (e) => {
|
|
611
|
+
if ((e.key === "Enter" || e.key === " ") && e.target.classList.contains("tree-row")) { e.preventDefault(); e.target.click(); }
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#openPanes(on) {
|
|
616
|
+
if (on === this.hasAttribute("panes-open")) return;
|
|
617
|
+
this.toggleAttribute("panes-open", on);
|
|
618
|
+
const q = (s) => this.#root.querySelector(s);
|
|
619
|
+
q(".pane-btn").setAttribute("aria-expanded", String(on));
|
|
620
|
+
if (!on) { this.#startAdd(null); q(".pane-btn").focus(); return; }
|
|
621
|
+
q(".pane-filter").value = "";
|
|
622
|
+
this.#loadPanes(true);
|
|
623
|
+
q(".pane-filter").focus();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async #loadPanes(expandCurrent = false) {
|
|
627
|
+
const hint = this.#root.querySelector(".drawer-hint");
|
|
628
|
+
hint.textContent = "불러오는 중…";
|
|
629
|
+
try {
|
|
630
|
+
const res = await fetch(`${this.server}/panes`);
|
|
631
|
+
const d = await res.json();
|
|
632
|
+
if (!d.ok) throw new Error(d.error);
|
|
633
|
+
this.#panes = d;
|
|
634
|
+
hint.textContent = "";
|
|
635
|
+
if (expandCurrent) {
|
|
636
|
+
const ws = d.workspaces.find((w) => w.tabs.some((t) => t.panes.some((p) => p.id === d.current)));
|
|
637
|
+
const tab = ws?.tabs.find((t) => t.panes.some((p) => p.id === d.current));
|
|
638
|
+
if (ws) this.#expanded.add(ws.id);
|
|
639
|
+
if (tab) this.#expanded.add(tab.id);
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
hint.textContent = "pane 목록을 불러오지 못했습니다.";
|
|
643
|
+
}
|
|
644
|
+
this.#renderPanes();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
#renderPanes() {
|
|
648
|
+
const tree = this.#root.querySelector(".tree");
|
|
649
|
+
const d = this.#panes;
|
|
650
|
+
if (!d) { tree.replaceChildren(); return; }
|
|
651
|
+
const filter = this.#root.querySelector(".pane-filter").value.trim().toLowerCase();
|
|
652
|
+
const hit = (...xs) => !filter || xs.some((x) => String(x ?? "").toLowerCase().includes(filter));
|
|
653
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
654
|
+
const row = (kind, id, cls, level, expanded) => {
|
|
655
|
+
const r = el("div", `tree-row ${cls}`);
|
|
656
|
+
Object.assign(r.dataset, { kind, id });
|
|
657
|
+
r.tabIndex = 0;
|
|
658
|
+
r.setAttribute("role", "treeitem");
|
|
659
|
+
r.setAttribute("aria-level", String(level));
|
|
660
|
+
if (expanded !== undefined) r.setAttribute("aria-expanded", String(expanded));
|
|
661
|
+
return r;
|
|
662
|
+
};
|
|
663
|
+
const addBtn = (label) => { const b = el("button", "row-add", "+"); b.type = "button"; b.title = label; b.setAttribute("aria-label", label); return b; };
|
|
664
|
+
const base = (p) => p.cwd?.split("/").filter(Boolean).pop();
|
|
665
|
+
const rows = [];
|
|
666
|
+
for (const ws of d.workspaces) {
|
|
667
|
+
const tabs = ws.tabs.map((t) => ({ ...t, panes: t.panes.filter((p) => hit(ws.label, t.label, p.id, p.agent, p.title, p.cwd)) }))
|
|
668
|
+
.filter((t) => t.panes.length);
|
|
669
|
+
if (filter && !tabs.length) continue;
|
|
670
|
+
const open = !!filter || this.#expanded.has(ws.id);
|
|
671
|
+
const r = row("ws", ws.id, "ws", 1, open);
|
|
672
|
+
const count = ws.tabs.reduce((n, t) => n + t.panes.length, 0);
|
|
673
|
+
r.append(el("span", "caret", open ? "▾" : "▸"), el("span", "name", ws.label || ws.id), el("span", "meta", String(count)), addBtn("tab 추가"));
|
|
674
|
+
rows.push(r);
|
|
675
|
+
if (!open) continue;
|
|
676
|
+
const single = ws.tabs.length === 1; // D5: a lone tab adds no information
|
|
677
|
+
for (const t of tabs) {
|
|
678
|
+
const tabOpen = single || !!filter || this.#expanded.has(t.id);
|
|
679
|
+
if (!single) {
|
|
680
|
+
const tr = row("tab", t.id, "tab", 2, tabOpen);
|
|
681
|
+
tr.append(el("span", "caret", tabOpen ? "▾" : "▸"), el("span", "name", `tab ${t.label}`), el("span", "meta", String(t.panes.length)));
|
|
682
|
+
rows.push(tr);
|
|
683
|
+
}
|
|
684
|
+
if (!tabOpen) continue;
|
|
685
|
+
for (const p of t.panes) {
|
|
686
|
+
const pr = row("pane", p.id, `pane${single ? "" : " nested"}${p.id === d.current ? " current" : ""}`, single ? 2 : 3);
|
|
687
|
+
pr.dataset.ws = ws.id;
|
|
688
|
+
if (p.id === d.current) pr.setAttribute("aria-current", "true");
|
|
689
|
+
const dot = el("span", `dot ${p.status}`);
|
|
690
|
+
dot.title = STATES[p.status] || p.status;
|
|
691
|
+
const name = el("span", "name", p.title || base(p) || p.id);
|
|
692
|
+
name.title = [p.title, p.cwd, p.id].filter(Boolean).join("\n");
|
|
693
|
+
const badge = el("span", "badge", p.agent || "셸");
|
|
694
|
+
if (p.agent !== "claude") badge.title = "채팅 미지원: 터미널로 연결";
|
|
695
|
+
pr.append(dot, name, badge, addBtn("pane 분할"));
|
|
696
|
+
rows.push(pr);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
tree.replaceChildren(...rows);
|
|
701
|
+
if (!rows.length) tree.append(el("div", "tree-empty", filter ? "검색 결과 없음" : "pane 없음"));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#startAdd(target) {
|
|
705
|
+
this.#adding = target;
|
|
706
|
+
const form = this.#root.querySelector(".add-form");
|
|
707
|
+
form.hidden = !target;
|
|
708
|
+
if (!target) return;
|
|
709
|
+
form.reset();
|
|
710
|
+
const titles = { workspace: "새 workspace", tab: "새 tab", pane: "pane 분할" };
|
|
711
|
+
form.querySelector(".add-title").textContent = titles[target.kind];
|
|
712
|
+
form.elements.label.hidden = target.kind === "pane";
|
|
713
|
+
form.elements.direction.hidden = target.kind !== "pane";
|
|
714
|
+
(target.kind === "pane" ? form.elements.direction : form.elements.label).focus();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async #submitAdd() {
|
|
718
|
+
const target = this.#adding;
|
|
719
|
+
if (!target) return;
|
|
720
|
+
const f = this.#root.querySelector(".add-form").elements;
|
|
721
|
+
const hint = this.#root.querySelector(".drawer-hint");
|
|
722
|
+
const body = { ...target, cwd: f.cwd.value.trim() || undefined };
|
|
723
|
+
if (target.kind === "pane") body.direction = f.direction.value;
|
|
724
|
+
else body.label = f.label.value.trim() || undefined;
|
|
725
|
+
if (body.cwd && !body.cwd.startsWith("/")) { hint.textContent = "폴더 경로는 /로 시작하는 절대 경로여야 합니다."; return; }
|
|
726
|
+
hint.textContent = "만드는 중…";
|
|
727
|
+
try {
|
|
728
|
+
const res = await fetch(`${this.server}/panes/create`, {
|
|
729
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
730
|
+
});
|
|
731
|
+
const d = await res.json();
|
|
732
|
+
if (!d.ok) throw new Error(d.error);
|
|
733
|
+
this.#startAdd(null);
|
|
734
|
+
if (target.workspace) this.#expanded.add(target.workspace);
|
|
735
|
+
await this.#selectPane(d.pane, { terminal: true }); // a new pane is a plain shell: show it as a terminal
|
|
736
|
+
} catch (e) {
|
|
737
|
+
hint.textContent = `추가 실패: ${e.message || "server_unreachable"}`;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async #selectPane(pane, { terminal = false } = {}) {
|
|
742
|
+
const hint = this.#root.querySelector(".drawer-hint");
|
|
743
|
+
if (pane === this.#panes?.current && !terminal) { this.#openPanes(false); return; }
|
|
744
|
+
hint.textContent = "연결 중…";
|
|
745
|
+
try {
|
|
746
|
+
const res = await fetch(`${this.server}/panes/connect`, {
|
|
747
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pane }),
|
|
748
|
+
});
|
|
749
|
+
const d = await res.json();
|
|
750
|
+
if (!d.ok) throw new Error(d.error);
|
|
751
|
+
const info = this.#panes?.workspaces.flatMap((w) => w.tabs.flatMap((t) => t.panes)).find((p) => p.id === pane);
|
|
752
|
+
hint.textContent = "";
|
|
753
|
+
this.#openPanes(false);
|
|
754
|
+
if (d.url !== this.server) this.setAttribute("server", d.url);
|
|
755
|
+
this.#emit("agent-pane", { pane, server: d.url });
|
|
756
|
+
if (terminal || (info && info.agent !== "claude")) this.#showTerminal(true); // D8: chat is Claude-only
|
|
757
|
+
} catch (e) {
|
|
758
|
+
hint.textContent = `연결 실패: ${e.message || "server_unreachable"}`;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---------- image attachments (paste / drop into the chat box) ----------
|
|
763
|
+
|
|
764
|
+
#bindImages() {
|
|
765
|
+
const composer = this.#root.querySelector(".composer");
|
|
766
|
+
const images = (list) => [...(list ?? [])].filter((f) => f.type?.startsWith("image/"));
|
|
767
|
+
this.$.box.addEventListener("paste", (e) => {
|
|
768
|
+
const files = images([...(e.clipboardData?.items ?? [])].filter((i) => i.kind === "file").map((i) => i.getAsFile()));
|
|
769
|
+
if (!files.length) return;
|
|
770
|
+
if (!e.clipboardData.getData("text/plain")) e.preventDefault();
|
|
771
|
+
this.#addImages(files);
|
|
772
|
+
});
|
|
773
|
+
composer.addEventListener("dragover", (e) => {
|
|
774
|
+
if (![...(e.dataTransfer?.items ?? [])].some((i) => i.kind === "file")) return;
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
composer.classList.add("dragover");
|
|
777
|
+
});
|
|
778
|
+
composer.addEventListener("dragleave", (e) => { if (!composer.contains(e.relatedTarget)) composer.classList.remove("dragover"); });
|
|
779
|
+
composer.addEventListener("drop", (e) => {
|
|
780
|
+
composer.classList.remove("dragover");
|
|
781
|
+
const files = images(e.dataTransfer?.files);
|
|
782
|
+
if (!files.length) return;
|
|
783
|
+
e.preventDefault();
|
|
784
|
+
this.#addImages(files);
|
|
785
|
+
});
|
|
786
|
+
this.#root.querySelector(".attachments").addEventListener("click", (e) => {
|
|
787
|
+
const i = e.target.closest("[data-remove]")?.dataset.remove;
|
|
788
|
+
if (i === undefined) return;
|
|
789
|
+
URL.revokeObjectURL(this.#images[i].url);
|
|
790
|
+
this.#images.splice(Number(i), 1);
|
|
791
|
+
this.#renderImages();
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async #addImages(files) {
|
|
796
|
+
const ok = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
797
|
+
for (const f of files) {
|
|
798
|
+
if (this.#images.length >= AgentBridge.IMAGE_MAX) { this.$.hint.textContent = `이미지는 최대 ${AgentBridge.IMAGE_MAX}개`; break; }
|
|
799
|
+
if (!ok.includes(f.type)) { this.$.hint.textContent = "PNG, JPEG, GIF, WebP만 첨부 가능"; continue; }
|
|
800
|
+
if (f.size > AgentBridge.IMAGE_BYTES) { this.$.hint.textContent = "이미지 1개당 최대 10MB"; continue; }
|
|
801
|
+
const data = await new Promise((resolve, reject) => {
|
|
802
|
+
const r = new FileReader();
|
|
803
|
+
r.onload = () => resolve(String(r.result).split(",")[1]);
|
|
804
|
+
r.onerror = reject;
|
|
805
|
+
r.readAsDataURL(f);
|
|
806
|
+
});
|
|
807
|
+
this.#images.push({ type: f.type, data, url: URL.createObjectURL(f) });
|
|
808
|
+
}
|
|
809
|
+
this.#renderImages();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#renderImages() {
|
|
813
|
+
this.#root.querySelector(".attachments").replaceChildren(...this.#images.map((img, i) => {
|
|
814
|
+
const wrap = document.createElement("div");
|
|
815
|
+
wrap.className = "attachment";
|
|
816
|
+
const pic = document.createElement("img");
|
|
817
|
+
pic.src = img.url;
|
|
818
|
+
pic.alt = `첨부 이미지 ${i + 1}`;
|
|
819
|
+
const rm = document.createElement("button");
|
|
820
|
+
rm.type = "button";
|
|
821
|
+
rm.textContent = "×";
|
|
822
|
+
rm.dataset.remove = String(i);
|
|
823
|
+
rm.setAttribute("aria-label", `첨부 이미지 ${i + 1} 삭제`);
|
|
824
|
+
wrap.append(pic, rm);
|
|
825
|
+
return wrap;
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
|
|
464
829
|
async send(text, action = "message", extra = {}) {
|
|
465
830
|
try {
|
|
466
831
|
const res = await fetch(`${this.server}/send`, {
|
|
@@ -555,7 +920,7 @@ class AgentBridge extends HTMLElement {
|
|
|
555
920
|
this.#emit("agent-present", this.#present);
|
|
556
921
|
this.#renderBusy();
|
|
557
922
|
if (on === was) return;
|
|
558
|
-
if (on) { this.#setControl(false); this.#setAnnotate(false); }
|
|
923
|
+
if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
|
|
559
924
|
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
560
925
|
this.#syncScreen();
|
|
561
926
|
// FLIP: grow from (or shrink back to) the side panel
|
|
@@ -1191,10 +1556,17 @@ class AgentBridge extends HTMLElement {
|
|
|
1191
1556
|
|
|
1192
1557
|
async #submit(action) {
|
|
1193
1558
|
const text = this.$.box.value.trim();
|
|
1194
|
-
|
|
1559
|
+
const images = this.#images.map(({ type, data }) => ({ type, data }));
|
|
1560
|
+
if (action === "message" && !text && !images.length) return;
|
|
1195
1561
|
this.$.buttons.forEach((b) => (b.disabled = true));
|
|
1196
|
-
const res = await this.send(text, action);
|
|
1197
|
-
if (res.ok) {
|
|
1562
|
+
const res = await this.send(text, action, { images: images.length ? images : undefined });
|
|
1563
|
+
if (res.ok) {
|
|
1564
|
+
this.$.box.value = "";
|
|
1565
|
+
this.$.hint.textContent = "";
|
|
1566
|
+
this.#images.forEach((img) => URL.revokeObjectURL(img.url));
|
|
1567
|
+
this.#images = [];
|
|
1568
|
+
this.#renderImages();
|
|
1569
|
+
}
|
|
1198
1570
|
else this.$.hint.textContent = `전송 실패: ${res.error}`;
|
|
1199
1571
|
this.#renderStatus(this.#status);
|
|
1200
1572
|
this.$.box.focus();
|
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
|
+
}
|