agello 0.4.2 → 0.4.3

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
@@ -113,7 +113,8 @@ The xterm.js renderer and its styles are served locally; no terminal CDN is requ
113
113
  - agent replies appear over it as bubbles that fade after 10s
114
114
  - `agello present stop` returns to the normal layout; the replies are in the chat as usual
115
115
  - the viewer can ask a question with the raise-hand button (its bubble fades after 10s too) or end the presentation with the end button; the agent then receives `[browser] action=present-stop`
116
- - raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing the input without asking sends `action=hand-lower`
116
+ - raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing it with X (or Esc) before asking sends `action=hand-lower`
117
+ - the input stays open for the whole question: after the agent answers, follow-up questions go in the same input (no need to raise the hand again); X (or Esc) then sends `action=hand-done`, and the agent resumes
117
118
 
118
119
  Run `present` again to move the crop. User control is off while presenting.
119
120
 
@@ -130,7 +131,8 @@ Write the talk ahead so each line appears the moment its screen does:
130
131
 
131
132
  - step: `go` brings the screen there (`#n` sets `location.hash`, anything else is JS run in the page; agello knows nothing about the deck), `say` lines are one bubble each, `hold` seconds a line stays on screen (default from length, 10 to 20s; never below 10s). Pacing: screen change, 1s, line, fade out, 0.3s, next line; after a step's last line 0.7s before the next screen
132
133
  - `present resume` plays from where it stopped (starts presentation mode with the script's `rect`); it first re-runs the current step's `go`, so the agent may move the screen freely while answering
133
- - raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
134
+ - raising a hand pauses at once and rewinds one line, so `present resume` replays the interrupted line; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.1`)
135
+ - raising, cancelling (X before asking) and finishing a question (X after an answer) show a small light event bubble for 5s right away, apart from the conversation bubbles; sending a question shows none
134
136
  - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
135
137
  - at the end the agent gets `action=present-done`; while the script is paused and the agent is working, the page shows a small loader
136
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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",
package/src/player.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // line is sent and held for its reading time (see PACE). pause() stops at once and keeps
10
10
  // the position, resume() re-runs the current step's `go` (the agent may have
11
11
  // moved the screen meanwhile) and continues from the next unshown line.
12
+ // rewind() (after a raised hand) moves back so the last shown line plays again.
12
13
  // goto() moves the position and shows that step without playing.
13
14
  //
14
15
  // Positions are "step.line", 1-based, in everything that leaves this module.
@@ -201,6 +202,15 @@ export function createPlayer(deps: {
201
202
  set("paused");
202
203
  },
203
204
 
205
+ // Paused by an interruption (raised hand): the next resume replays the
206
+ // last shown line, since the viewer may have missed it.
207
+ rewind() {
208
+ if (state !== "paused" || !last) return;
209
+ cur = { ...last };
210
+ entered = false;
211
+ deps.changed();
212
+ },
213
+
204
214
  // Move to a step (and line) and show its screen. Does not play; if it was
205
215
  // playing, it keeps playing from there.
206
216
  async goto(pos: { step: number; line: number }): Promise<string | null> {
package/src/server.ts CHANGED
@@ -530,10 +530,11 @@ export async function startServer(opts: ServerOptions) {
530
530
  if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
531
531
  text = t;
532
532
  }
533
- // Raised hand: stop the script at once (no agent round trip) and tell the
534
- // agent where it stopped.
533
+ // Raised hand: stop the script at once (no agent round trip), rewind so
534
+ // resume replays the interrupted line, and tell the agent where it stopped.
535
535
  if (action === "hand-raise") {
536
536
  player.pause();
537
+ player.rewind();
537
538
  const at = where();
538
539
  if (at) text += ` (대본 정지: ${at})`;
539
540
  }
@@ -42,7 +42,9 @@
42
42
  // A raise-hand button next to the bubbles opens a small input so the viewer
43
43
  // can ask the agent something mid-presentation (sent as action=message).
44
44
  // Opening it tells the agent right away (action=hand-raise) so it can pause;
45
- // closing it without asking sends action=hand-lower so it can go on.
45
+ // closing it with X (or Esc) before asking sends action=hand-lower so it can go on.
46
+ // The input stays open while the agent answers, for follow-up questions; X
47
+ // (or Esc) sends action=hand-done so the agent resumes the presentation.
46
48
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
47
49
  // An end button above it stops the presentation for every viewer.
48
50
  // Script lines (`agello script` + `present resume`) arrive as agent messages
@@ -81,7 +83,7 @@ const SCREEN_REASONS = {
81
83
  };
82
84
  const ACTIONS = {
83
85
  message: "메시지", approve: "승인", reject: "거절",
84
- "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
86
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", "hand-done": "질문 완료", annotate: "주석",
85
87
  };
86
88
 
87
89
  const CSS = `
@@ -276,6 +278,9 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
276
278
  box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
277
279
  .caption.plain { white-space: pre-wrap; }
278
280
  .caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
281
+ /* system event (hand raised / lowered / done): small, light, not part of the conversation */
282
+ .caption.event { padding: 6px 14px; border-radius: 999px; font-size: 13px; color: #1d1d1f;
283
+ background: rgba(255, 255, 255, .9); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
279
284
  .caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
280
285
  .caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
281
286
  .caption p, .caption ul, .caption ol { margin: .35em 0; }
@@ -317,6 +322,13 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
317
322
  .ask textarea::placeholder { color: rgba(255,255,255,.5); }
318
323
  .ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
319
324
  .ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
325
+ .ask .ask-row .primary { margin-left: 0; }
326
+ .ask .ask-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; color: rgba(255,255,255,.75); }
327
+ .ask .ask-title { flex: 1; }
328
+ .ask .ask-done { width: 28px; height: 28px; padding: 0; border-radius: 50%; display: grid; place-items: center;
329
+ background: transparent; color: #fff; border-color: rgba(255,255,255,.22); }
330
+ .ask .ask-done:hover { background: rgba(255,255,255,.14); }
331
+ .ask .ask-done svg { width: 16px; height: 16px; }
320
332
  @media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
321
333
  @media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
322
334
  @container (max-width: 820px) {
@@ -447,7 +459,10 @@ const TEMPLATE = `
447
459
  <div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
448
460
  <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
449
461
  <div class="hand-wrap">
450
- <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
462
+ <form class="ask" part="ask" hidden>
463
+ <div class="ask-head"><span class="ask-title"></span><button type="button" class="ask-done">
464
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></div>
465
+ <textarea aria-label="에이전트에게 질문"></textarea>
451
466
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
452
467
  <button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
453
468
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
@@ -491,6 +506,7 @@ class AgentBridge extends HTMLElement {
491
506
  #present = { on: false };
492
507
  static HISTORY_LIMIT = 500;
493
508
  static CAPTION_MS = 10000; // presentation bubble lifetime
509
+ static EVENT_MS = 5000; // system event bubble lifetime
494
510
  static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
495
511
 
496
512
  constructor() {
@@ -505,6 +521,7 @@ class AgentBridge extends HTMLElement {
505
521
  controlBtn: q(".control-btn"), kbd: q(".kbd"),
506
522
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
507
523
  busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
524
+ askTitle: q(".ask-title"), askDone: q(".ask-done"),
508
525
  annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
509
526
  note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
510
527
  };
@@ -873,6 +890,10 @@ class AgentBridge extends HTMLElement {
873
890
  // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
874
891
  if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
875
892
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
893
+ // a reply to the viewer's question (status polling may miss a short working phase)
894
+ // (the agent took the question once it is echoed in the transcript; earlier work was the hand-raise turn)
895
+ if (this.#qa === "asked" && m.role === "browser" && m.action === "message") this.#qaTaken = true;
896
+ if (this.#qa === "asked" && this.#qaTaken && m.role === "assistant" && !m.script) { this.#qaWorked = true; this.#checkAnswered(); }
876
897
  this.#emit("agent-message", m);
877
898
  });
878
899
  on("present", (p) => this.#setPresent(p));
@@ -921,7 +942,7 @@ class AgentBridge extends HTMLElement {
921
942
  this.#renderBusy();
922
943
  if (on === was) return;
923
944
  if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
924
- else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
945
+ else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#qa = null; }
925
946
  this.#syncScreen();
926
947
  // FLIP: grow from (or shrink back to) the side panel
927
948
  const { screen, body } = this.$;
@@ -940,20 +961,31 @@ class AgentBridge extends HTMLElement {
940
961
  const s = this.#status;
941
962
  const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
942
963
  this.$.busy.classList.toggle("on", !!busy);
964
+ this.#checkAnswered();
943
965
  }
944
966
 
945
- // Raise hand: open a small input over the presentation and send it as a message.
967
+ // Raise hand: a question session over the presentation.
968
+ // The input stays open for the whole session; X / Esc / the hand button close it.
969
+ // raised: nothing asked yet. close -> hand-lower (agent resumes)
970
+ // asked: question sent, agent answering (follow-ups can be typed already)
971
+ // follow: answered, waiting for a follow-up. close -> hand-done (agent resumes)
972
+ // The session ends when the script plays again or the presentation stops.
973
+ #qa = null; // null | "raised" | "asked" | "follow"
974
+ #qaTaken = false; // the last question reached the agent (echoed in the transcript)
975
+ #qaWorked = false; // agent seen working on it
976
+
946
977
  #bindHand() {
947
978
  const { hand, ask, askBox, askHint } = this.$;
948
979
  hand.addEventListener("click", () => {
949
- if (ask.hidden) this.#raiseHand();
950
- else this.#lowerHand();
980
+ if (!this.#qa) this.#raiseHand();
981
+ else this.#closeHand();
951
982
  });
983
+ this.$.askDone.addEventListener("click", () => { this.#closeHand(); hand.focus(); });
952
984
  this.$.end.addEventListener("click", async () => {
953
985
  this.$.end.disabled = true;
954
986
  const res = await this.stopPresent();
955
987
  this.$.end.disabled = false;
956
- if (!res.ok) { this.#openAsk(true); askHint.textContent = `발표 종료 실패: ${res.error}`; }
988
+ if (!res.ok) { this.#openAsk(this.#qa ?? "raised"); askHint.textContent = `발표 종료 실패: ${res.error}`; }
957
989
  });
958
990
  ask.addEventListener("submit", async (e) => {
959
991
  e.preventDefault();
@@ -963,40 +995,85 @@ class AgentBridge extends HTMLElement {
963
995
  const res = await this.send(text, "message");
964
996
  if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
965
997
  askBox.value = "";
966
- this.#handUp = false; // the question itself ends the raised hand
967
- this.#openAsk(false);
998
+ this.#qa = "asked";
999
+ this.#qaTaken = this.#qaWorked = false;
1000
+ this.#openAsk("asked");
968
1001
  hand.classList.add("sent");
969
1002
  setTimeout(() => hand.classList.remove("sent"), 1500);
970
1003
  });
971
1004
  askBox.addEventListener("keydown", (e) => {
972
1005
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
973
- if (e.key === "Escape") { e.preventDefault(); this.#lowerHand(); hand.focus(); }
1006
+ if (e.key === "Escape") { e.preventDefault(); this.#closeHand(); hand.focus(); }
974
1007
  });
975
1008
  }
976
1009
 
977
- // Raise: open the input and tell the agent to pause. Lower (closed without
978
- // asking): tell it to go on. Failures only show a hint; asking still works.
979
- #handUp = false;
1010
+ // Raise: open the input and tell the agent to pause. Failures only show a hint; asking still works.
980
1011
  async #raiseHand() {
981
- this.#openAsk(true);
982
- if (this.#handUp) return;
983
- this.#handUp = true;
1012
+ this.#event("손들기 확인 · 발표 일시정지");
1013
+ this.#qa = "raised";
1014
+ this.#openAsk("raised");
984
1015
  const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
985
1016
  if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
986
1017
  }
1018
+ // X / Esc / hand button: before asking = cancel (hand-lower), after = resolved (hand-done).
1019
+ #closeHand() {
1020
+ if (this.#qa === "raised") this.#lowerHand(); else this.#doneHand();
1021
+ }
1022
+ // Cancel before asking: tell the agent to go on.
987
1023
  async #lowerHand() {
988
1024
  this.#openAsk(false);
989
- if (!this.#handUp) return;
990
- this.#handUp = false;
991
- await this.send("사용자가 질문 없이 손을 내림", "hand-lower");
1025
+ if (this.#qa !== "raised") return;
1026
+ this.#qa = null;
1027
+ this.#event("손들기 취소 · 곧 발표 재개");
1028
+ await this.send("사용자가 질문 없이 손을 내림: 발표 계속", "hand-lower");
1029
+ }
1030
+ // X after an answer: the question is resolved, tell the agent to go on.
1031
+ async #doneHand() {
1032
+ this.#openAsk(false);
1033
+ if (!this.#qa || this.#qa === "raised") return;
1034
+ this.#qa = null;
1035
+ this.#event("질문 완료 · 곧 발표 재개");
1036
+ await this.send("사용자가 질문 완료 (추가 질문 없음): 발표 계속", "hand-done");
992
1037
  }
993
1038
 
994
- #openAsk(open) {
995
- const { hand, ask, askBox, askHint } = this.$;
996
- ask.hidden = !open;
997
- hand.setAttribute("aria-expanded", String(open));
1039
+ // Answer finished (agent was working, now is not): reopen the input for a follow-up.
1040
+ // If the script plays again (agent resumed on its own), the session is over.
1041
+ #checkAnswered() {
1042
+ if (!this.#qa) return;
1043
+ if (this.#present.player?.state === "playing") {
1044
+ if (this.#qa !== "raised") { this.#qa = null; this.#openAsk(false); }
1045
+ return;
1046
+ }
1047
+ if (this.#qa !== "asked") return;
1048
+ const s = this.#status;
1049
+ if (s?.alive && s.status === "working") { if (this.#qaTaken) this.#qaWorked = true; return; }
1050
+ if (this.#qaWorked && s?.alive) { this.#qa = "follow"; this.#openAsk("follow"); }
1051
+ }
1052
+
1053
+ // The input stays open for the whole session. mode: false (close) | "raised" | "asked" | "follow"
1054
+ static ASK_TEXT = {
1055
+ raised: ["질문 입력", "에이전트에게 질문 (Enter 전송, Esc·X 취소)", "손들기 취소: 발표 계속"],
1056
+ asked: ["답변 준비 중…", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
1057
+ follow: ["추가 질문이 있으면 입력", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
1058
+ };
1059
+ #openAsk(mode) {
1060
+ const { hand, ask, askBox, askHint, askTitle, askDone } = this.$;
1061
+ const wasOpen = !ask.hidden;
1062
+ ask.hidden = !mode;
1063
+ hand.setAttribute("aria-expanded", String(!!mode));
998
1064
  askHint.textContent = "";
999
- if (open) askBox.focus();
1065
+ if (!mode) return;
1066
+ const [title, placeholder, done] = AgentBridge.ASK_TEXT[mode];
1067
+ askTitle.textContent = title;
1068
+ askBox.placeholder = placeholder;
1069
+ askDone.setAttribute("aria-label", done);
1070
+ askDone.title = done;
1071
+ if (!wasOpen || mode !== "asked") askBox.focus();
1072
+ }
1073
+
1074
+ // Instant local feedback for a hand interaction (no agent round trip).
1075
+ #event(text) {
1076
+ this.#caption(text, "event", AgentBridge.EVENT_MS);
1000
1077
  }
1001
1078
 
1002
1079
  // Agent reply as a short-lived bubble over the presented screen.
@@ -1006,7 +1083,7 @@ class AgentBridge extends HTMLElement {
1006
1083
  const el = document.createElement("div");
1007
1084
  el.className = `caption ${kind}`;
1008
1085
  el.part = "caption";
1009
- if (kind === "user") { el.textContent = text; el.classList.add("plain"); }
1086
+ if (kind === "user" || kind === "event") { el.textContent = text; el.classList.add("plain"); }
1010
1087
  else if (this.#md) el.innerHTML = this.#md(text);
1011
1088
  else { el.textContent = text; el.classList.add("plain"); }
1012
1089
  const box = this.$.captions;