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 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
- Currently supports **Claude Code** sessions.
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`. Anything allowed to post to `/send` can type into your agent session, so keep the allow list short.
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.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 { homedir } from "node:os";
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
- herdrTab: async () => (await agentInfo())?.tab_id,
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 = url.pathname === "/agent-bridge.js";
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) ? undefined : new Response("websocket required", { status: 426 });
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
  },
@@ -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
+ }
@@ -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: e.g. {target} for annotate)
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="clear-btn" title="이 pane의 저장된 대화 삭제">기록 지우기</button><span class="dot"></span><span class="state">연결 중</span></div></header>
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 줄바꿈)"></textarea>
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"), clearBtn: q(".clear-btn"),
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
- this.$.clearBtn.addEventListener("click", () => this.clearHistory());
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
- if (action === "message" && !text) return;
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) { this.$.box.value = ""; this.$.hint.textContent = ""; }
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>
@@ -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
+ }