agello 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -22
- package/bin/agello.ts +0 -68
- package/package.json +6 -2
- package/src/panes.ts +86 -0
- package/src/screencast.ts +1 -8
- package/src/server.ts +82 -93
- package/src/terminal.ts +122 -0
- package/web/agent-bridge.js +389 -39
- package/web/index.html +12 -0
- package/web/terminal.js +121 -0
- package/src/player.ts +0 -234
package/web/agent-bridge.js
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
// (tool calls are also kept in the conversation as one-line rows, like the terminal)
|
|
25
25
|
// agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
|
|
26
26
|
// agent-present detail: { on, rect?: {x,y,w,h}, since? }
|
|
27
|
+
// agent-pane detail: { pane, server } another pane was picked in the drawer (server attribute changed)
|
|
28
|
+
//
|
|
29
|
+
// Pane picker
|
|
30
|
+
// Clicking the status in the header opens a drawer with herdr's workspace -> tab -> pane tree.
|
|
31
|
+
// Picking a pane switches to that pane's own server (started on demand). Workspaces, tabs and
|
|
32
|
+
// panes can be added there; nothing is closed from the page (closing kills the pane's processes).
|
|
27
33
|
//
|
|
28
34
|
// Presentation mode (started by the agent: `agello present [x,y,w,h]`)
|
|
29
35
|
// The screen panel expands to fill the window and shows only the given
|
|
@@ -36,12 +42,9 @@
|
|
|
36
42
|
// closing it without asking sends action=hand-lower so it can go on.
|
|
37
43
|
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
38
44
|
// An end button above it stops the presentation for every viewer.
|
|
39
|
-
// Script lines (`agello script` + `present resume`) arrive as agent messages
|
|
40
|
-
// with `script: "step.line"`. While the script is not playing and the agent
|
|
41
|
-
// is working (e.g. answering a question), a small loader is shown.
|
|
42
45
|
//
|
|
43
46
|
// Methods
|
|
44
|
-
// el.send(text, action = 'message') -> Promise<{ok, error?}>
|
|
47
|
+
// el.send(text, action = 'message', images = [{type, data (base64)}]) -> Promise<{ok, error?}>
|
|
45
48
|
// el.clearHistory() clear saved chat for the current pane
|
|
46
49
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
47
50
|
|
|
@@ -76,6 +79,57 @@ const ACTIONS = {
|
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
const CSS = `
|
|
82
|
+
.pane-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px; border-radius: 999px; font-size: 12px;
|
|
83
|
+
color: var(--_muted); max-width: 46cqi; }
|
|
84
|
+
.pane-btn .state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
85
|
+
.pane-btn .dot { flex: none; }
|
|
86
|
+
.drawer-backdrop { display: none; position: absolute; inset: 0; z-index: 20; background: rgb(0 0 0 / .25); }
|
|
87
|
+
.drawer { position: absolute; top: 0; right: 0; bottom: 0; z-index: 21; width: min(360px, 92%); display: flex;
|
|
88
|
+
flex-direction: column; background: var(--_panel); border-left: 1px solid var(--_line);
|
|
89
|
+
box-shadow: -8px 0 24px rgb(0 0 0 / .18); transform: translateX(100%); visibility: hidden;
|
|
90
|
+
transition: transform .18s ease, visibility 0s .18s; }
|
|
91
|
+
:host([panes-open]) .drawer { transform: none; visibility: visible; transition: transform .18s ease; }
|
|
92
|
+
:host([panes-open]) .drawer-backdrop { display: block; }
|
|
93
|
+
.drawer-head { display: flex; gap: 6px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--_line); }
|
|
94
|
+
.drawer-head strong { font-size: 13px; margin-right: auto; }
|
|
95
|
+
.icon-btn { padding: 1px 8px; border-radius: 6px; font-size: 14px; line-height: 1.5; }
|
|
96
|
+
.pane-filter { width: 100%; padding: 5px 9px; font: inherit; font-size: 12px; border: 1px solid var(--_line);
|
|
97
|
+
border-radius: 7px; background: var(--_bg); color: var(--_text); }
|
|
98
|
+
.drawer-search { padding: 8px 12px; border-bottom: 1px solid var(--_line); }
|
|
99
|
+
.add-form { display: grid; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--_line); font-size: 12px; }
|
|
100
|
+
.add-form[hidden] { display: none; }
|
|
101
|
+
.add-form .add-title { font-weight: 600; }
|
|
102
|
+
.add-form input, .add-form select { padding: 4px 8px; font: inherit; border: 1px solid var(--_line); border-radius: 6px;
|
|
103
|
+
background: var(--_bg); color: var(--_text); }
|
|
104
|
+
.add-form .add-row { display: flex; gap: 6px; justify-content: flex-end; }
|
|
105
|
+
.tree { flex: 1; overflow: auto; padding: 6px 0; font-size: 13px; }
|
|
106
|
+
.tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 10px; cursor: pointer; min-width: 0; }
|
|
107
|
+
.tree-row:hover { background: var(--_user); }
|
|
108
|
+
.tree-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
109
|
+
.tree-row .meta { font-size: 11px; color: var(--_muted); flex: none; }
|
|
110
|
+
.tree-row .caret { width: 12px; flex: none; color: var(--_muted); font-size: 10px; }
|
|
111
|
+
.tree-row .row-add { visibility: hidden; padding: 0 6px; font-size: 12px; border-radius: 5px; flex: none; }
|
|
112
|
+
.tree-row:hover .row-add, .tree-row:focus-within .row-add { visibility: visible; }
|
|
113
|
+
.tree-row.ws .name { font-weight: 600; }
|
|
114
|
+
.tree-row.tab { padding-left: 26px; color: var(--_muted); }
|
|
115
|
+
.tree-row.pane { padding-left: 30px; }
|
|
116
|
+
.tree-row.pane.nested { padding-left: 46px; }
|
|
117
|
+
.tree-row.pane.current { background: color-mix(in srgb, var(--_accent) 16%, transparent); }
|
|
118
|
+
.tree-row .badge { font-size: 10px; padding: 0 6px; border-radius: 999px; border: 1px solid var(--_line); color: var(--_muted); flex: none; }
|
|
119
|
+
.tree-empty, .drawer-hint { padding: 8px 12px; font-size: 12px; color: var(--_muted); }
|
|
120
|
+
.drawer-hint:empty { display: none; }
|
|
121
|
+
.term-btn { display: inline-flex; align-items: center; justify-content: center; padding: 3px 7px; border-radius: 999px; }
|
|
122
|
+
.term-btn svg { width: 14px; height: 14px; }
|
|
123
|
+
.term-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: white; }
|
|
124
|
+
.terminal-panel { display: none; flex: 1; min-height: 0; flex-direction: column; background: #101114; }
|
|
125
|
+
:host([terminal-open]) .chat > .stage, :host([terminal-open]) .chat > footer { display: none; }
|
|
126
|
+
:host([terminal-open]) .terminal-panel { display: flex; }
|
|
127
|
+
.terminal-bar { display: flex; gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid #26272b; }
|
|
128
|
+
.terminal-status { flex: 1; font-size: 12px; color: #a1a1aa; overflow-wrap: anywhere; }
|
|
129
|
+
.terminal-retry { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
|
|
130
|
+
.terminal-host { flex: 1; min-height: 0; padding: 8px 12px; overflow: hidden; }
|
|
131
|
+
.terminal-host textarea { min-height: 0; border: 0; padding: 0; resize: none; }
|
|
132
|
+
|
|
79
133
|
:host {
|
|
80
134
|
--_bg: var(--ab-bg, #f6f6f4); --_panel: var(--ab-panel, #fff); --_text: var(--ab-text, #1f1f1f);
|
|
81
135
|
--_muted: var(--ab-muted, #6b6b6b); --_line: var(--ab-line, #e4e4e0); --_accent: var(--ab-accent, #2f6fed);
|
|
@@ -100,7 +154,6 @@ header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; bord
|
|
|
100
154
|
background: var(--_panel); }
|
|
101
155
|
:host([no-header]) header { display: none; }
|
|
102
156
|
h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
103
|
-
.clear-btn { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
|
|
104
157
|
.session { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--_muted); }
|
|
105
158
|
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--_muted); }
|
|
106
159
|
.dot.idle, .dot.done { background: var(--_ok); } .dot.working { background: var(--_busy); }
|
|
@@ -202,14 +255,6 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
202
255
|
.caption a { color: inherit; }
|
|
203
256
|
@keyframes capIn { from { opacity: 0; translate: 0 10px; } }
|
|
204
257
|
|
|
205
|
-
/* agent busy while presenting (answering, fixing the script): minimal loader */
|
|
206
|
-
.busy { display: none; }
|
|
207
|
-
.body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
|
|
208
|
-
gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
|
|
209
|
-
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
|
|
210
|
-
animation: capIn .25s ease; }
|
|
211
|
-
.busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
|
|
212
|
-
|
|
213
258
|
/* raise hand: ask the agent mid-presentation */
|
|
214
259
|
.hand-wrap { display: none; }
|
|
215
260
|
.body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
|
|
@@ -314,18 +359,38 @@ button.primary { background: var(--_accent); border-color: var(--_accent); color
|
|
|
314
359
|
button:disabled { cursor: not-allowed; opacity: .45; }
|
|
315
360
|
:host([no-actions]) .secondary { display: none; }
|
|
316
361
|
.hint { font-size: 12px; color: var(--_muted); }
|
|
362
|
+
.attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
363
|
+
.attachments:empty { display: none; }
|
|
364
|
+
.attachment { position: relative; width: 64px; height: 64px; border: 1px solid var(--_line); border-radius: 8px; overflow: hidden; }
|
|
365
|
+
.attachment img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
366
|
+
.attachment button { position: absolute; top: 2px; right: 2px; padding: 0 6px; font-size: 12px; line-height: 1.5;
|
|
367
|
+
border-radius: 999px; background: rgb(0 0 0 / .6); border: 0; color: #fff; }
|
|
368
|
+
.composer.dragover textarea { outline: 2px dashed var(--_accent); outline-offset: -1px; }
|
|
317
369
|
`;
|
|
318
370
|
|
|
319
371
|
const TEMPLATE = `
|
|
320
|
-
<header><h1 part="heading"></h1><div class="session"><button class="
|
|
372
|
+
<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>
|
|
373
|
+
<div class="drawer-backdrop"></div>
|
|
374
|
+
<aside class="drawer" role="dialog" aria-label="pane 선택">
|
|
375
|
+
<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>
|
|
376
|
+
<div class="drawer-search"><input class="pane-filter" type="search" placeholder="workspace, 제목, 폴더 검색" aria-label="pane 검색"></div>
|
|
377
|
+
<form class="add-form" hidden><span class="add-title"></span>
|
|
378
|
+
<input name="label" placeholder="이름 (선택)" maxlength="80">
|
|
379
|
+
<input name="cwd" placeholder="폴더 경로 (선택, 절대 경로)">
|
|
380
|
+
<select name="direction" aria-label="분할 방향"><option value="right">오른쪽으로 분할</option><option value="down">아래로 분할</option></select>
|
|
381
|
+
<div class="add-row"><button type="button" class="add-cancel">취소</button><button type="submit" class="primary">추가</button></div></form>
|
|
382
|
+
<div class="tree" role="tree"></div><div class="drawer-hint" role="status"></div>
|
|
383
|
+
</aside>
|
|
321
384
|
<div class="body"><section class="chat">
|
|
385
|
+
<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>
|
|
322
386
|
<div class="stage">
|
|
323
387
|
<div class="log" part="log"><div class="inner"><div class="empty">이 pane의 대화가 브라우저에 저장돼요.</div></div><div class="inner queue" part="queue"></div></div>
|
|
324
388
|
<div class="activity" part="activity"></div>
|
|
325
389
|
</div>
|
|
326
390
|
<footer part="composer"><div class="composer">
|
|
327
391
|
<div class="grip" role="separator" aria-orientation="horizontal" aria-label="입력창 높이 조절 (위아래 방향키)" tabindex="0" title="끌어서 입력창 높이 조절"></div>
|
|
328
|
-
<textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter
|
|
392
|
+
<textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈, 이미지 붙여넣기·끌어놓기)"></textarea>
|
|
393
|
+
<div class="attachments" aria-label="첨부 이미지"></div>
|
|
329
394
|
<div class="row">
|
|
330
395
|
<button class="secondary" data-action="approve">승인</button>
|
|
331
396
|
<button class="secondary" data-action="reject">거절</button>
|
|
@@ -339,7 +404,6 @@ const TEMPLATE = `
|
|
|
339
404
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
340
405
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
341
406
|
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="captions" part="captions" aria-live="polite"></div>
|
|
342
|
-
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
343
407
|
<div class="hand-wrap">
|
|
344
408
|
<form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
|
|
345
409
|
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
@@ -359,6 +423,15 @@ class AgentBridge extends HTMLElement {
|
|
|
359
423
|
#es = null;
|
|
360
424
|
#screenEs = null;
|
|
361
425
|
#controlWs = null;
|
|
426
|
+
#terminalCleanup = null;
|
|
427
|
+
#terminalVersion = 0;
|
|
428
|
+
#terminalStyles = null;
|
|
429
|
+
#panes = null; // {current, workspaces}
|
|
430
|
+
#expanded = new Set();
|
|
431
|
+
#adding = null; // {kind, workspace?, pane?}
|
|
432
|
+
#images = []; // chat attachments: {type, data (base64), url (preview)}
|
|
433
|
+
static IMAGE_MAX = 5;
|
|
434
|
+
static IMAGE_BYTES = 10 * 1024 * 1024;
|
|
362
435
|
#frame = { w: 0, h: 0 };
|
|
363
436
|
#moveQueued = null;
|
|
364
437
|
#status = null;
|
|
@@ -380,9 +453,9 @@ class AgentBridge extends HTMLElement {
|
|
|
380
453
|
activity: q(".activity"), box: q("textarea"), hint: q(".hint"),
|
|
381
454
|
buttons: this.#root.querySelectorAll("button[data-action]"),
|
|
382
455
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
383
|
-
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
456
|
+
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
384
457
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
385
|
-
|
|
458
|
+
hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
386
459
|
};
|
|
387
460
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
388
461
|
this.#bindGrip();
|
|
@@ -395,7 +468,10 @@ class AgentBridge extends HTMLElement {
|
|
|
395
468
|
loadMarkdown().then((md) => (this.#md = md));
|
|
396
469
|
this.#bindControl();
|
|
397
470
|
this.#bindHand();
|
|
398
|
-
|
|
471
|
+
q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
|
|
472
|
+
q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
|
|
473
|
+
this.#bindPanes();
|
|
474
|
+
this.#bindImages();
|
|
399
475
|
}
|
|
400
476
|
|
|
401
477
|
get server() {
|
|
@@ -408,6 +484,7 @@ class AgentBridge extends HTMLElement {
|
|
|
408
484
|
this.#syncScreen();
|
|
409
485
|
}
|
|
410
486
|
disconnectedCallback() {
|
|
487
|
+
this.#showTerminal(false);
|
|
411
488
|
this.#es?.close();
|
|
412
489
|
this.#es = null;
|
|
413
490
|
this.#screenEs?.close();
|
|
@@ -416,16 +493,291 @@ class AgentBridge extends HTMLElement {
|
|
|
416
493
|
}
|
|
417
494
|
attributeChangedCallback(name) {
|
|
418
495
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
419
|
-
if (name === "server" && this.isConnected) { this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
496
|
+
if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
420
497
|
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
421
498
|
}
|
|
422
499
|
|
|
423
|
-
async
|
|
500
|
+
async #showTerminal(on) {
|
|
501
|
+
const version = ++this.#terminalVersion;
|
|
502
|
+
this.#terminalCleanup?.();
|
|
503
|
+
this.#terminalCleanup = null;
|
|
504
|
+
this.toggleAttribute("terminal-open", on);
|
|
505
|
+
const q = (s) => this.#root.querySelector(s);
|
|
506
|
+
q(".term-btn").setAttribute("aria-pressed", String(on));
|
|
507
|
+
if (!on) return;
|
|
508
|
+
this.#setControl(false);
|
|
509
|
+
q(".terminal-status").textContent = "터미널 로딩 중…";
|
|
510
|
+
try {
|
|
511
|
+
if (!this.#terminalStyles) {
|
|
512
|
+
const link = document.createElement("link");
|
|
513
|
+
link.rel = "stylesheet";
|
|
514
|
+
link.href = `${SCRIPT_ORIGIN}/terminal.css`;
|
|
515
|
+
link.dataset.terminalCss = "";
|
|
516
|
+
this.#terminalStyles = new Promise((resolve, reject) => {
|
|
517
|
+
link.onload = resolve;
|
|
518
|
+
link.onerror = () => { this.#terminalStyles = null; link.remove(); reject(new Error("Stylesheet failed")); };
|
|
519
|
+
});
|
|
520
|
+
this.#root.append(link);
|
|
521
|
+
}
|
|
522
|
+
await this.#terminalStyles;
|
|
523
|
+
const { mountTerminal } = await import(`${SCRIPT_ORIGIN}/terminal.js`);
|
|
524
|
+
if (version !== this.#terminalVersion || !this.isConnected) return;
|
|
525
|
+
this.#terminalCleanup = mountTerminal(q(".terminal-host"), q(".terminal-status"), this.server);
|
|
526
|
+
} catch {
|
|
527
|
+
if (version === this.#terminalVersion) q(".terminal-status").textContent = "터미널을 불러오지 못했습니다. 다시 연결해 주세요.";
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------- pane picker (herdr workspace -> tab -> pane) ----------
|
|
532
|
+
|
|
533
|
+
#bindPanes() {
|
|
534
|
+
const q = (s) => this.#root.querySelector(s);
|
|
535
|
+
q(".pane-btn").addEventListener("click", () => this.#openPanes(!this.hasAttribute("panes-open")));
|
|
536
|
+
q(".drawer-close").addEventListener("click", () => this.#openPanes(false));
|
|
537
|
+
q(".drawer-backdrop").addEventListener("click", () => this.#openPanes(false));
|
|
538
|
+
q(".panes-refresh").addEventListener("click", () => this.#loadPanes());
|
|
539
|
+
q(".ws-add").addEventListener("click", () => this.#startAdd({ kind: "workspace" }));
|
|
540
|
+
q(".pane-filter").addEventListener("input", () => this.#renderPanes());
|
|
541
|
+
q(".add-cancel").addEventListener("click", () => this.#startAdd(null));
|
|
542
|
+
q(".add-form").addEventListener("submit", (e) => { e.preventDefault(); this.#submitAdd(); });
|
|
543
|
+
q(".drawer").addEventListener("keydown", (e) => {
|
|
544
|
+
if (e.key === "Escape") { e.stopPropagation(); this.#adding ? this.#startAdd(null) : this.#openPanes(false); }
|
|
545
|
+
});
|
|
546
|
+
q(".tree").addEventListener("click", (e) => {
|
|
547
|
+
const add = e.target.closest(".row-add");
|
|
548
|
+
const row = e.target.closest(".tree-row");
|
|
549
|
+
if (!row) return;
|
|
550
|
+
const { kind, id, ws } = row.dataset;
|
|
551
|
+
if (add) return this.#startAdd(kind === "ws" ? { kind: "tab", workspace: id } : { kind: "pane", pane: id, workspace: ws });
|
|
552
|
+
if (kind === "pane") return this.#selectPane(id);
|
|
553
|
+
this.#expanded.has(id) ? this.#expanded.delete(id) : this.#expanded.add(id);
|
|
554
|
+
this.#renderPanes();
|
|
555
|
+
});
|
|
556
|
+
q(".tree").addEventListener("keydown", (e) => {
|
|
557
|
+
if ((e.key === "Enter" || e.key === " ") && e.target.classList.contains("tree-row")) { e.preventDefault(); e.target.click(); }
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#openPanes(on) {
|
|
562
|
+
if (on === this.hasAttribute("panes-open")) return;
|
|
563
|
+
this.toggleAttribute("panes-open", on);
|
|
564
|
+
const q = (s) => this.#root.querySelector(s);
|
|
565
|
+
q(".pane-btn").setAttribute("aria-expanded", String(on));
|
|
566
|
+
if (!on) { this.#startAdd(null); q(".pane-btn").focus(); return; }
|
|
567
|
+
q(".pane-filter").value = "";
|
|
568
|
+
this.#loadPanes(true);
|
|
569
|
+
q(".pane-filter").focus();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async #loadPanes(expandCurrent = false) {
|
|
573
|
+
const hint = this.#root.querySelector(".drawer-hint");
|
|
574
|
+
hint.textContent = "불러오는 중…";
|
|
575
|
+
try {
|
|
576
|
+
const res = await fetch(`${this.server}/panes`);
|
|
577
|
+
const d = await res.json();
|
|
578
|
+
if (!d.ok) throw new Error(d.error);
|
|
579
|
+
this.#panes = d;
|
|
580
|
+
hint.textContent = "";
|
|
581
|
+
if (expandCurrent) {
|
|
582
|
+
const ws = d.workspaces.find((w) => w.tabs.some((t) => t.panes.some((p) => p.id === d.current)));
|
|
583
|
+
const tab = ws?.tabs.find((t) => t.panes.some((p) => p.id === d.current));
|
|
584
|
+
if (ws) this.#expanded.add(ws.id);
|
|
585
|
+
if (tab) this.#expanded.add(tab.id);
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
hint.textContent = "pane 목록을 불러오지 못했습니다.";
|
|
589
|
+
}
|
|
590
|
+
this.#renderPanes();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#renderPanes() {
|
|
594
|
+
const tree = this.#root.querySelector(".tree");
|
|
595
|
+
const d = this.#panes;
|
|
596
|
+
if (!d) { tree.replaceChildren(); return; }
|
|
597
|
+
const filter = this.#root.querySelector(".pane-filter").value.trim().toLowerCase();
|
|
598
|
+
const hit = (...xs) => !filter || xs.some((x) => String(x ?? "").toLowerCase().includes(filter));
|
|
599
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
600
|
+
const row = (kind, id, cls, level, expanded) => {
|
|
601
|
+
const r = el("div", `tree-row ${cls}`);
|
|
602
|
+
Object.assign(r.dataset, { kind, id });
|
|
603
|
+
r.tabIndex = 0;
|
|
604
|
+
r.setAttribute("role", "treeitem");
|
|
605
|
+
r.setAttribute("aria-level", String(level));
|
|
606
|
+
if (expanded !== undefined) r.setAttribute("aria-expanded", String(expanded));
|
|
607
|
+
return r;
|
|
608
|
+
};
|
|
609
|
+
const addBtn = (label) => { const b = el("button", "row-add", "+"); b.type = "button"; b.title = label; b.setAttribute("aria-label", label); return b; };
|
|
610
|
+
const base = (p) => p.cwd?.split("/").filter(Boolean).pop();
|
|
611
|
+
const rows = [];
|
|
612
|
+
for (const ws of d.workspaces) {
|
|
613
|
+
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)) }))
|
|
614
|
+
.filter((t) => t.panes.length);
|
|
615
|
+
if (filter && !tabs.length) continue;
|
|
616
|
+
const open = !!filter || this.#expanded.has(ws.id);
|
|
617
|
+
const r = row("ws", ws.id, "ws", 1, open);
|
|
618
|
+
const count = ws.tabs.reduce((n, t) => n + t.panes.length, 0);
|
|
619
|
+
r.append(el("span", "caret", open ? "▾" : "▸"), el("span", "name", ws.label || ws.id), el("span", "meta", String(count)), addBtn("tab 추가"));
|
|
620
|
+
rows.push(r);
|
|
621
|
+
if (!open) continue;
|
|
622
|
+
const single = ws.tabs.length === 1; // D5: a lone tab adds no information
|
|
623
|
+
for (const t of tabs) {
|
|
624
|
+
const tabOpen = single || !!filter || this.#expanded.has(t.id);
|
|
625
|
+
if (!single) {
|
|
626
|
+
const tr = row("tab", t.id, "tab", 2, tabOpen);
|
|
627
|
+
tr.append(el("span", "caret", tabOpen ? "▾" : "▸"), el("span", "name", `tab ${t.label}`), el("span", "meta", String(t.panes.length)));
|
|
628
|
+
rows.push(tr);
|
|
629
|
+
}
|
|
630
|
+
if (!tabOpen) continue;
|
|
631
|
+
for (const p of t.panes) {
|
|
632
|
+
const pr = row("pane", p.id, `pane${single ? "" : " nested"}${p.id === d.current ? " current" : ""}`, single ? 2 : 3);
|
|
633
|
+
pr.dataset.ws = ws.id;
|
|
634
|
+
if (p.id === d.current) pr.setAttribute("aria-current", "true");
|
|
635
|
+
const dot = el("span", `dot ${p.status}`);
|
|
636
|
+
dot.title = STATES[p.status] || p.status;
|
|
637
|
+
const name = el("span", "name", p.title || base(p) || p.id);
|
|
638
|
+
name.title = [p.title, p.cwd, p.id].filter(Boolean).join("\n");
|
|
639
|
+
const badge = el("span", "badge", p.agent || "셸");
|
|
640
|
+
if (p.agent !== "claude") badge.title = "채팅 미지원: 터미널로 연결";
|
|
641
|
+
pr.append(dot, name, badge, addBtn("pane 분할"));
|
|
642
|
+
rows.push(pr);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
tree.replaceChildren(...rows);
|
|
647
|
+
if (!rows.length) tree.append(el("div", "tree-empty", filter ? "검색 결과 없음" : "pane 없음"));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#startAdd(target) {
|
|
651
|
+
this.#adding = target;
|
|
652
|
+
const form = this.#root.querySelector(".add-form");
|
|
653
|
+
form.hidden = !target;
|
|
654
|
+
if (!target) return;
|
|
655
|
+
form.reset();
|
|
656
|
+
const titles = { workspace: "새 workspace", tab: "새 tab", pane: "pane 분할" };
|
|
657
|
+
form.querySelector(".add-title").textContent = titles[target.kind];
|
|
658
|
+
form.elements.label.hidden = target.kind === "pane";
|
|
659
|
+
form.elements.direction.hidden = target.kind !== "pane";
|
|
660
|
+
(target.kind === "pane" ? form.elements.direction : form.elements.label).focus();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async #submitAdd() {
|
|
664
|
+
const target = this.#adding;
|
|
665
|
+
if (!target) return;
|
|
666
|
+
const f = this.#root.querySelector(".add-form").elements;
|
|
667
|
+
const hint = this.#root.querySelector(".drawer-hint");
|
|
668
|
+
const body = { ...target, cwd: f.cwd.value.trim() || undefined };
|
|
669
|
+
if (target.kind === "pane") body.direction = f.direction.value;
|
|
670
|
+
else body.label = f.label.value.trim() || undefined;
|
|
671
|
+
if (body.cwd && !body.cwd.startsWith("/")) { hint.textContent = "폴더 경로는 /로 시작하는 절대 경로여야 합니다."; return; }
|
|
672
|
+
hint.textContent = "만드는 중…";
|
|
673
|
+
try {
|
|
674
|
+
const res = await fetch(`${this.server}/panes/create`, {
|
|
675
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
676
|
+
});
|
|
677
|
+
const d = await res.json();
|
|
678
|
+
if (!d.ok) throw new Error(d.error);
|
|
679
|
+
this.#startAdd(null);
|
|
680
|
+
if (target.workspace) this.#expanded.add(target.workspace);
|
|
681
|
+
await this.#selectPane(d.pane, { terminal: true }); // a new pane is a plain shell: show it as a terminal
|
|
682
|
+
} catch (e) {
|
|
683
|
+
hint.textContent = `추가 실패: ${e.message || "server_unreachable"}`;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async #selectPane(pane, { terminal = false } = {}) {
|
|
688
|
+
const hint = this.#root.querySelector(".drawer-hint");
|
|
689
|
+
if (pane === this.#panes?.current && !terminal) { this.#openPanes(false); return; }
|
|
690
|
+
hint.textContent = "연결 중…";
|
|
691
|
+
try {
|
|
692
|
+
const res = await fetch(`${this.server}/panes/connect`, {
|
|
693
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pane }),
|
|
694
|
+
});
|
|
695
|
+
const d = await res.json();
|
|
696
|
+
if (!d.ok) throw new Error(d.error);
|
|
697
|
+
const info = this.#panes?.workspaces.flatMap((w) => w.tabs.flatMap((t) => t.panes)).find((p) => p.id === pane);
|
|
698
|
+
hint.textContent = "";
|
|
699
|
+
this.#openPanes(false);
|
|
700
|
+
if (d.url !== this.server) this.setAttribute("server", d.url);
|
|
701
|
+
this.#emit("agent-pane", { pane, server: d.url });
|
|
702
|
+
if (terminal || (info && info.agent !== "claude")) this.#showTerminal(true); // D8: chat is Claude-only
|
|
703
|
+
} catch (e) {
|
|
704
|
+
hint.textContent = `연결 실패: ${e.message || "server_unreachable"}`;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---------- image attachments (paste / drop into the chat box) ----------
|
|
709
|
+
|
|
710
|
+
#bindImages() {
|
|
711
|
+
const composer = this.#root.querySelector(".composer");
|
|
712
|
+
const images = (list) => [...(list ?? [])].filter((f) => f.type?.startsWith("image/"));
|
|
713
|
+
this.$.box.addEventListener("paste", (e) => {
|
|
714
|
+
const files = images([...(e.clipboardData?.items ?? [])].filter((i) => i.kind === "file").map((i) => i.getAsFile()));
|
|
715
|
+
if (!files.length) return;
|
|
716
|
+
if (!e.clipboardData.getData("text/plain")) e.preventDefault();
|
|
717
|
+
this.#addImages(files);
|
|
718
|
+
});
|
|
719
|
+
composer.addEventListener("dragover", (e) => {
|
|
720
|
+
if (![...(e.dataTransfer?.items ?? [])].some((i) => i.kind === "file")) return;
|
|
721
|
+
e.preventDefault();
|
|
722
|
+
composer.classList.add("dragover");
|
|
723
|
+
});
|
|
724
|
+
composer.addEventListener("dragleave", (e) => { if (!composer.contains(e.relatedTarget)) composer.classList.remove("dragover"); });
|
|
725
|
+
composer.addEventListener("drop", (e) => {
|
|
726
|
+
composer.classList.remove("dragover");
|
|
727
|
+
const files = images(e.dataTransfer?.files);
|
|
728
|
+
if (!files.length) return;
|
|
729
|
+
e.preventDefault();
|
|
730
|
+
this.#addImages(files);
|
|
731
|
+
});
|
|
732
|
+
this.#root.querySelector(".attachments").addEventListener("click", (e) => {
|
|
733
|
+
const i = e.target.closest("[data-remove]")?.dataset.remove;
|
|
734
|
+
if (i === undefined) return;
|
|
735
|
+
URL.revokeObjectURL(this.#images[i].url);
|
|
736
|
+
this.#images.splice(Number(i), 1);
|
|
737
|
+
this.#renderImages();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async #addImages(files) {
|
|
742
|
+
const ok = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
743
|
+
for (const f of files) {
|
|
744
|
+
if (this.#images.length >= AgentBridge.IMAGE_MAX) { this.$.hint.textContent = `이미지는 최대 ${AgentBridge.IMAGE_MAX}개`; break; }
|
|
745
|
+
if (!ok.includes(f.type)) { this.$.hint.textContent = "PNG, JPEG, GIF, WebP만 첨부 가능"; continue; }
|
|
746
|
+
if (f.size > AgentBridge.IMAGE_BYTES) { this.$.hint.textContent = "이미지 1개당 최대 10MB"; continue; }
|
|
747
|
+
const data = await new Promise((resolve, reject) => {
|
|
748
|
+
const r = new FileReader();
|
|
749
|
+
r.onload = () => resolve(String(r.result).split(",")[1]);
|
|
750
|
+
r.onerror = reject;
|
|
751
|
+
r.readAsDataURL(f);
|
|
752
|
+
});
|
|
753
|
+
this.#images.push({ type: f.type, data, url: URL.createObjectURL(f) });
|
|
754
|
+
}
|
|
755
|
+
this.#renderImages();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
#renderImages() {
|
|
759
|
+
this.#root.querySelector(".attachments").replaceChildren(...this.#images.map((img, i) => {
|
|
760
|
+
const wrap = document.createElement("div");
|
|
761
|
+
wrap.className = "attachment";
|
|
762
|
+
const pic = document.createElement("img");
|
|
763
|
+
pic.src = img.url;
|
|
764
|
+
pic.alt = `첨부 이미지 ${i + 1}`;
|
|
765
|
+
const rm = document.createElement("button");
|
|
766
|
+
rm.type = "button";
|
|
767
|
+
rm.textContent = "×";
|
|
768
|
+
rm.dataset.remove = String(i);
|
|
769
|
+
rm.setAttribute("aria-label", `첨부 이미지 ${i + 1} 삭제`);
|
|
770
|
+
wrap.append(pic, rm);
|
|
771
|
+
return wrap;
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async send(text, action = "message", images = []) {
|
|
424
776
|
try {
|
|
425
777
|
const res = await fetch(`${this.server}/send`, {
|
|
426
778
|
method: "POST",
|
|
427
779
|
headers: { "Content-Type": "application/json" },
|
|
428
|
-
body: JSON.stringify({ action, text }),
|
|
780
|
+
body: JSON.stringify({ action, text, images: images.length ? images : undefined }),
|
|
429
781
|
});
|
|
430
782
|
return await res.json();
|
|
431
783
|
} catch {
|
|
@@ -464,8 +816,7 @@ class AgentBridge extends HTMLElement {
|
|
|
464
816
|
on("message", (m) => {
|
|
465
817
|
this.#addMessage(m);
|
|
466
818
|
this.#saveMessage(m);
|
|
467
|
-
|
|
468
|
-
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
819
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text);
|
|
469
820
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
470
821
|
this.#emit("agent-message", m);
|
|
471
822
|
});
|
|
@@ -511,9 +862,8 @@ class AgentBridge extends HTMLElement {
|
|
|
511
862
|
const was = this.#present.on;
|
|
512
863
|
this.#present = p ?? { on: false };
|
|
513
864
|
this.#emit("agent-present", this.#present);
|
|
514
|
-
this.#renderBusy();
|
|
515
865
|
if (on === was) return;
|
|
516
|
-
if (on) this.#setControl(false);
|
|
866
|
+
if (on) { this.#showTerminal(false); this.#setControl(false); }
|
|
517
867
|
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
518
868
|
this.#syncScreen();
|
|
519
869
|
// FLIP: grow from (or shrink back to) the side panel
|
|
@@ -529,12 +879,6 @@ class AgentBridge extends HTMLElement {
|
|
|
529
879
|
{ duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
|
|
530
880
|
}
|
|
531
881
|
|
|
532
|
-
#renderBusy() {
|
|
533
|
-
const s = this.#status;
|
|
534
|
-
const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
|
|
535
|
-
this.$.busy.classList.toggle("on", !!busy);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
882
|
// Raise hand: open a small input over the presentation and send it as a message.
|
|
539
883
|
#bindHand() {
|
|
540
884
|
const { hand, ask, askBox, askHint } = this.$;
|
|
@@ -594,7 +938,7 @@ class AgentBridge extends HTMLElement {
|
|
|
594
938
|
|
|
595
939
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
596
940
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
597
|
-
#caption(text, kind = "agent"
|
|
941
|
+
#caption(text, kind = "agent") {
|
|
598
942
|
if (!text?.trim()) return;
|
|
599
943
|
const el = document.createElement("div");
|
|
600
944
|
el.className = `caption ${kind}`;
|
|
@@ -609,7 +953,7 @@ class AgentBridge extends HTMLElement {
|
|
|
609
953
|
el.classList.add("out");
|
|
610
954
|
setTimeout(() => el.remove(), 500);
|
|
611
955
|
};
|
|
612
|
-
setTimeout(leave,
|
|
956
|
+
setTimeout(leave, AgentBridge.CAPTION_MS);
|
|
613
957
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
614
958
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
615
959
|
el.__leave = leave;
|
|
@@ -904,7 +1248,7 @@ class AgentBridge extends HTMLElement {
|
|
|
904
1248
|
return;
|
|
905
1249
|
} else if (m.role === "assistant") {
|
|
906
1250
|
wrap.className = "msg assistant";
|
|
907
|
-
meta.textContent =
|
|
1251
|
+
meta.textContent = `에이전트 · ${time}`;
|
|
908
1252
|
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
909
1253
|
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
910
1254
|
} else if (m.role === "browser") {
|
|
@@ -966,16 +1310,22 @@ class AgentBridge extends HTMLElement {
|
|
|
966
1310
|
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
967
1311
|
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
968
1312
|
this.#renderActivity();
|
|
969
|
-
this.#renderBusy();
|
|
970
1313
|
this.#emit("agent-status", s);
|
|
971
1314
|
}
|
|
972
1315
|
|
|
973
1316
|
async #submit(action) {
|
|
974
1317
|
const text = this.$.box.value.trim();
|
|
975
|
-
|
|
1318
|
+
const images = this.#images.map(({ type, data }) => ({ type, data }));
|
|
1319
|
+
if (action === "message" && !text && !images.length) return;
|
|
976
1320
|
this.$.buttons.forEach((b) => (b.disabled = true));
|
|
977
|
-
const res = await this.send(text, action);
|
|
978
|
-
if (res.ok) {
|
|
1321
|
+
const res = await this.send(text, action, images);
|
|
1322
|
+
if (res.ok) {
|
|
1323
|
+
this.$.box.value = "";
|
|
1324
|
+
this.$.hint.textContent = "";
|
|
1325
|
+
this.#images.forEach((img) => URL.revokeObjectURL(img.url));
|
|
1326
|
+
this.#images = [];
|
|
1327
|
+
this.#renderImages();
|
|
1328
|
+
}
|
|
979
1329
|
else this.$.hint.textContent = `전송 실패: ${res.error}`;
|
|
980
1330
|
this.#renderStatus(this.#status);
|
|
981
1331
|
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>
|