huddle-cli 0.1.1 → 0.1.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.
@@ -0,0 +1,56 @@
1
+ // One plain-English line on top of a live run: what's happening now, and
2
+ // whether you're needed.
3
+ //
4
+ // Found live (0.1.2 test): with four agents' notes scrolling by, the user
5
+ // couldn't tell what the team was doing, or whether it was waiting on them.
6
+ // The narrator answers both, every second, with words, not engineering:
7
+ // Now: the agents are each sketching how they'd build it · nothing needed from you
8
+ // Now: building — 2 of 8 parts done · claude: argv parser · codex: conversion core 25%
9
+ // ⚑ claude has a question for you — answer below · picks option 1 in 41s
10
+ import { fmtDuration } from "../adapters/usage.js";
11
+ import { C, truncate } from "./term.js";
12
+ const COUNCIL = {
13
+ 1: "the agents are each sketching how they'd build it",
14
+ 2: "they're reading each other's ideas: what to borrow, what could go wrong",
15
+ 3: "one agent is writing the plan from the best ideas",
16
+ 4: "the others are double-checking the plan before anyone writes code",
17
+ };
18
+ /** "Build strict positional argv parser with UsageError" → "strict positional argv parser". */
19
+ function shortTitle(t, n = 32) {
20
+ const s = t.replace(/^(build|create|write|add|implement|make|set up)\s+(the\s+|a\s+|an\s+)?/i, "").replace(/\s+(with|and|for|that)\s.*$/i, "");
21
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
22
+ }
23
+ /** The narrator's words for the run as it stands (no colors), and whether it's waiting on you. */
24
+ export function narration(stats, now = Date.now()) {
25
+ const ask = stats.ask;
26
+ if (ask && ask.until > now)
27
+ return { needsYou: true, text: `${ask.from} has a question for you — answer below · picks option 1 in ${fmtDuration(ask.until - now)}` };
28
+ const r = stats.run;
29
+ if (!r)
30
+ return { needsYou: false, text: "getting started" };
31
+ if (r.phase === "COUNCIL")
32
+ return { needsYou: false, text: COUNCIL[r.step ?? 1] ?? COUNCIL[1] };
33
+ if (r.phase === "PLAN")
34
+ return { needsYou: false, text: "splitting the work into parts" };
35
+ if (r.phase === "DONE")
36
+ return { needsYou: false, text: "done — the summary is below" };
37
+ const busy = [...stats.agents].filter(([, a]) => a.busySince && a.doing);
38
+ const who = busy.map(([id, a]) => `${id}: ${shortTitle(a.doing)}${a.pct ? ` ${a.pct}%` : ""}`);
39
+ if (r.phase === "QA")
40
+ return { needsYou: false, text: `testing the finished build: agents try to break parts they didn't write${r.round ? ` (round ${r.round})` : ""}${who.length ? " · " + who.join(" · ") : ""}` };
41
+ const done = r.total ? `${r.done} of ${r.total} parts done` : "starting";
42
+ return { needsYou: false, text: `building — ${done}${r.failed ? `, ${r.failed} stuck` : ""}${who.length ? " · " + who.join(" · ") : ""}` };
43
+ }
44
+ /** The narrator line, cut to `W` cells: " Now: … · nothing needed from you". */
45
+ export function narratorLine(stats, W, now = Date.now()) {
46
+ const n = narration(stats, now);
47
+ if (n.needsYou)
48
+ return truncate(` ${C.yellow("⚑ " + n.text)}`, W);
49
+ const calm = C.faint(" · nothing needed from you");
50
+ const head = ` ${C.bold(C.violet("Now:"))} `;
51
+ // The reassurance stays; the detail gives way.
52
+ const room = W - 2 - 5 - 27;
53
+ const text = n.text.length > room && room > 12 ? n.text.slice(0, room - 1) + "…" : n.text;
54
+ return truncate(`${head}${text}${calm}`, W);
55
+ }
56
+ //# sourceMappingURL=narrator.js.map
package/dist/ui/story.js CHANGED
@@ -17,7 +17,7 @@ import { C, dot, pad, truncate, AGENT_COLOR } from "./term.js";
17
17
  import { fmtDuration, fmtTokens } from "../adapters/usage.js";
18
18
  import { isHelpOffer } from "../room/progress.js";
19
19
  /** The scheduler's own event types, persisted as "run.<type>" (scheduler.ts). */
20
- const CORE_RUN = new Set(["start", "declined", "tokens", "review", "reviewed", "merged", "failed", "stuck", "conflict", "resolved", "handoff"]);
20
+ export const CORE_RUN = new Set(["start", "declined", "tokens", "review", "reviewed", "merged", "failed", "stuck", "conflict", "resolved", "handoff"]);
21
21
  const NOT_AGENTS = new Set(["huddle", "room", "all", "you", "council", ""]);
22
22
  function parse(e) {
23
23
  let p = {};
@@ -40,9 +40,9 @@ function parse(e) {
40
40
  delete p.type;
41
41
  return { ts: e.ts, type, agent: e.agent_id, task: e.task_id, p };
42
42
  }
43
- const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v));
43
+ export const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v));
44
44
  /** First non-empty field among `keys`. */
45
- const pick = (p, ...keys) => {
45
+ export const pick = (p, ...keys) => {
46
46
  for (const k of keys) {
47
47
  const v = p[k];
48
48
  if (v !== undefined && v !== null && str(v).trim())
@@ -50,7 +50,7 @@ const pick = (p, ...keys) => {
50
50
  }
51
51
  return "";
52
52
  };
53
- const oneLine = (s, n = 140) => {
53
+ export const oneLine = (s, n = 140) => {
54
54
  const l = s.replace(/\s+/g, " ").trim();
55
55
  return l.length <= n ? l : l.slice(0, n - 1) + "…";
56
56
  };
@@ -74,7 +74,7 @@ function qaEyes(checkers, authors) {
74
74
  return "(didn't write it)";
75
75
  return mine.length === checkers.length ? "(wrote some of it too — checking anyway)" : `(${mine.join(", ")} wrote some of it too)`;
76
76
  }
77
- const list = (v) => {
77
+ export const list = (v) => {
78
78
  if (Array.isArray(v))
79
79
  return v.map(str);
80
80
  if (typeof v === "string") {
@@ -373,6 +373,8 @@ export function buildStory(run, runs = 1) {
373
373
  case "memory.added": {
374
374
  const author = pick(p, "author") || e.agent || "";
375
375
  const body = pick(p, "body");
376
+ if (author === "you")
377
+ break; // your words: already their message or answer
376
378
  if (author === "huddle") {
377
379
  // Mostly the scheduler restating what run.* says; the red gate is news.
378
380
  if (/red test gate/.test(body))
@@ -452,6 +454,19 @@ export function buildStory(run, runs = 1) {
452
454
  st(to).received++;
453
455
  break;
454
456
  }
457
+ // An agent's question for the person at the keyboard (huddle_ask_user), and what came back.
458
+ case "user.ask": {
459
+ const from = pick(p, "from") || e.agent || "?";
460
+ const opts = list(p.options);
461
+ add({ ts, kind: "message", type: e.type, agent: from, to: "you", text: oneLine(pick(p, "question"), 400), detail: `question for you · ${opts.map((o, i) => `${i + 1}. ${o}`).join(" · ")}` });
462
+ break;
463
+ }
464
+ case "user.answer": {
465
+ const to = pick(p, "from") || e.agent || "?";
466
+ const mine = pick(p, "by") === "you";
467
+ add({ ts, kind: "message", type: e.type, agent: mine ? "you" : "huddle", to, text: oneLine(pick(p, "answer"), 400), detail: mine ? "your answer" : "no answer in time · went with option 1" });
468
+ break;
469
+ }
455
470
  case "handoff": {
456
471
  const from = pick(p, "from") || e.agent;
457
472
  const to = pick(p, "to");
@@ -562,6 +577,14 @@ export function buildStory(run, runs = 1) {
562
577
  case "run.stuck":
563
578
  add({ ts, kind: "fail", type: e.type, agent: null, text: `! blocked by failures: ${list(p.blocked).join(" · ")}` });
564
579
  break;
580
+ case "busy": // quiet past the silence window, but working: not taken over (busy.ts)
581
+ add({ ts, kind: "progress", type: e.type, agent: pick(p, "agent") || e.agent, text: `⧗ running a long command: quiet ${Math.round((Number(p.quietMs) || 0) / 60_000) || "<1"} min, but busy — not taken over`, detail: oneLine(pick(p, "why"), 200) || undefined });
582
+ break;
583
+ case "budget": {
584
+ const left = list(p.left);
585
+ add({ ts, kind: "fail", type: e.type, agent: null, text: `! token budget ${p.level === "hard" ? "ran over: agents stopped" : "nearly spent: no new work started"} (${Math.round(((Number(p.tokens) || 0) / (Number(p.limit) || 1)) * 100)}% of ${fmtTokens(Number(p.limit) || 0)})`, detail: left.length ? `left undone: ${left.join(" · ")}` : undefined });
586
+ break;
587
+ }
565
588
  case "run.conflict": {
566
589
  const a = pick(p, "agent") || e.agent || "?";
567
590
  const author = pick(p, "author");
@@ -922,7 +945,7 @@ export function collabLines(s) {
922
945
  out.push({ label: "credit", text: [...c.credit.entries()].sort((a, b) => b[1] - a[1]).map(([a, k]) => `${a} ${n(k, "task")}`).join(" · ") });
923
946
  return out;
924
947
  }
925
- function crewRows(s) {
948
+ export function crewRows(s) {
926
949
  const done = s.items.filter((it) => it.type === "qa.done");
927
950
  return s.agents.map((a) => {
928
951
  const x = s.stats.get(a);
@@ -930,14 +953,14 @@ function crewRows(s) {
930
953
  return { agent: a, merged: x.merged, failed: x.failed, reviews: x.reviews, qa, ms: x.ms, tokens: x.tokens, cached: x.cached };
931
954
  });
932
955
  }
933
- const plural = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
956
+ export const plural = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
934
957
  /** "5 merged · 3 reviews · 1 QA" — what an agent did, nothing for what it didn't. */
935
- function didLine(c) {
958
+ export function didLine(c) {
936
959
  const parts = [`${c.merged} merged`, c.failed ? `${c.failed} failed` : "", c.reviews ? plural(c.reviews, "review") : "", c.qa ? `${c.qa} QA` : ""];
937
960
  return parts.filter(Boolean).join(" · ");
938
961
  }
939
962
  /** The run cut into its phases: the build, then QA (from the first QA round on). */
940
- function phases(s) {
963
+ export function phases(s) {
941
964
  // The council's decisions and risks, written to shared memory as the build starts: part of the plan.
942
965
  const agreed = s.items.filter((it) => it.agent === "council" && it.type === "memory.added");
943
966
  const rest = s.items.filter((it) => !agreed.includes(it));
@@ -945,14 +968,14 @@ function phases(s) {
945
968
  return i < 0 ? { agreed, build: rest, qa: [] } : { agreed, build: rest.slice(0, i), qa: rest.slice(i) };
946
969
  }
947
970
  /** A council decision or risk from shared memory, as "decided" / "risk (by)". */
948
- function agreement(it) {
971
+ export function agreement(it) {
949
972
  const risk = /^gotcha: council risk:/.test(it.text);
950
973
  return { risk, by: risk ? /flagged by (\S+)/.exec(it.detail ?? "")?.[1] : undefined, body: it.text.replace(/^\w+: council( risk)?:\s*/, "") };
951
974
  }
952
975
  /** The council is recorded in one go when it's done: its items share one instant, so they get no clock. */
953
976
  const councilInstant = (s) => s.design.length > 1 && s.design.at(-1).ts - s.design[0].ts < 2000;
954
977
  /** How the run ended: totals, merges gated on tests, QA's last word. */
955
- function outcome(s) {
978
+ export function outcome(s) {
956
979
  const rows = crewRows(s);
957
980
  const tokens = rows.reduce((n, c) => n + c.tokens, 0);
958
981
  const cached = rows.reduce((n, c) => n + c.cached, 0);
@@ -964,7 +987,7 @@ function outcome(s) {
964
987
  return { tokens, cached, merges: merges.length, green, qa, rounds, ms: s.run.end - s.run.start };
965
988
  }
966
989
  /** The teamwork as numbers, for the summary line and the HTML tiles. Only what happened (or was looked for). */
967
- function tally(s) {
990
+ export function tally(s) {
968
991
  const c = s.collab;
969
992
  const out = [];
970
993
  if (c.adopts.length || c.flaws.length) {
@@ -997,7 +1020,7 @@ function tally(s) {
997
1020
  return out;
998
1021
  }
999
1022
  /** Each buddy note with how it ended: fixed in code, answered, ignored — or just sent. */
1000
- function buddyNotes(s) {
1023
+ export function buddyNotes(s) {
1001
1024
  const left = s.collab.followups.filter((f) => f.kind !== "radar");
1002
1025
  return s.collab.buddy
1003
1026
  .filter((b) => b.notes)
@@ -1010,7 +1033,7 @@ function buddyNotes(s) {
1010
1033
  });
1011
1034
  }
1012
1035
  // ---- terminal -----------------------------------------------------------------
1013
- const clock = (ms) => {
1036
+ export const clock = (ms) => {
1014
1037
  const s = Math.max(0, Math.round(ms / 1000));
1015
1038
  return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
1016
1039
  };
@@ -1208,10 +1231,88 @@ export function renderRunList(runs) {
1208
1231
  .join("\n");
1209
1232
  }
1210
1233
  // ---- HTML (a static, self-contained report file) --------------------------------
1211
- const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1212
- const colorOf = (a) => (a ? AGENT_COLOR[a.split("#")[0]] ?? "#8A857C" : "#7B6CF6");
1234
+ export const esc = (s) => str(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1235
+ export const colorOf = (a) => (a ? AGENT_COLOR[a.split("#")[0]] ?? "#8A857C" : "#7B6CF6");
1213
1236
  /** Lines that are the agents thinking out loud, hidden behind a toggle so the story reads first. */
1214
1237
  const NOISE = (it) => it.kind === "progress" || it.kind === "announce" || it.kind === "memory" || it.type === "memory.added";
1238
+ /** The story page's look — shared with `huddle export --format html` (src/ui/export.ts). */
1239
+ export const STORY_CSS = `:root{--bg:#f6f5f1;--fg:#1d1b18;--mute:#6f6a62;--line:#e4e0d8;--card:#fff;--soft:#efede7;--ok:#23864c;--bad:#c93f30;--warn:#a86f05;--brand:#6a5af0;color-scheme:light}
1240
+ @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#121117;--fg:#ece9e3;--mute:#9a948b;--line:#2b2932;--card:#1b1a21;--soft:#23222a;--ok:#4cc27c;--bad:#f06a5b;--warn:#e0a53a;--brand:#9488ff;color-scheme:dark}}
1241
+ :root[data-theme="dark"]{--bg:#121117;--fg:#ece9e3;--mute:#9a948b;--line:#2b2932;--card:#1b1a21;--soft:#23222a;--ok:#4cc27c;--bad:#f06a5b;--warn:#e0a53a;--brand:#9488ff;color-scheme:dark}
1242
+ *{box-sizing:border-box}
1243
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
1244
+ main{max-width:1100px;margin:0 auto;padding:24px 16px 60px}
1245
+ h1{font-size:26px;line-height:1.25;margin:6px 0 4px;word-break:break-word}
1246
+ h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--brand);margin:36px 0 12px}
1247
+ h2 small{text-transform:none;letter-spacing:0}
1248
+ h3{font-size:14px;margin:0 0 10px}
1249
+ .brand{font-weight:700;color:var(--brand);font-size:13px;letter-spacing:.04em}
1250
+ .sub,small,.detail,time,.meta,.none,.legend{color:var(--mute)}small{font-size:12px;margin-left:6px;font-weight:400}
1251
+ .none{font-size:13px;margin:6px 0}
1252
+ .who{display:inline-flex;align-items:center;gap:5px;font-weight:600;white-space:nowrap}.who i{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1253
+ .hero{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px}
1254
+ .hero details{margin:8px 0}
1255
+ .kpis,.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-top:16px}
1256
+ .kpi,.tile{background:var(--soft);border-radius:12px;padding:10px 12px;min-width:0}
1257
+ .kpi b,.tile b{display:block;font-size:24px;line-height:1.2;font-variant-numeric:tabular-nums}
1258
+ .kpi span,.tile span{font-size:12px;color:var(--mute)}
1259
+ .kpi.ok b,.tile.ok b{color:var(--ok)}.kpi.bad b,.tile.bad b{color:var(--bad)}.kpi.warn b,.tile.warn b{color:var(--warn)}
1260
+ .verdict{margin:14px 0 0;font-weight:600}.verdict.ok{color:var(--ok)}.verdict.warn{color:var(--warn)}
1261
+ .crew{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;margin-top:12px}
1262
+ .agent{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--c);border-radius:12px;padding:10px 14px;min-width:0}
1263
+ .agent .an{display:flex;align-items:center;gap:7px;font-weight:700;font-size:16px}.agent .an i{width:11px;height:11px;border-radius:50%;background:var(--c)}
1264
+ .agent .as{font-size:14px}.agent .am{font-size:12px;color:var(--mute);font-variant-numeric:tabular-nums}
1265
+ .cards{columns:320px 3;column-gap:12px;margin-top:12px}.cards .card{break-inside:avoid;margin-bottom:12px}
1266
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;min-width:0}
1267
+ .list li{font-size:14px;padding:7px 0;border-top:1px solid var(--line);word-break:break-word}.list li:first-child{border-top:0}
1268
+ .list li.mute{color:var(--mute)}
1269
+ .badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:var(--soft);color:var(--mute);white-space:nowrap}
1270
+ .badge.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}.badge.bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 14%,transparent)}.badge.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 16%,transparent)}
1271
+ .grid td,.grid th{text-align:center}.grid th:first-child{text-align:left}
1272
+ .ad,.fl{display:inline-block;min-width:22px;padding:1px 6px;margin:1px;border-radius:6px;font-weight:700;font-size:12px}
1273
+ .ad{color:var(--ok);background:color-mix(in srgb,var(--ok) 15%,transparent)}.fl{color:var(--warn);background:color-mix(in srgb,var(--warn) 17%,transparent)}.z{color:var(--line)}
1274
+ .legend{font-size:12px;margin:8px 0}
1275
+ .quote{font-size:13px;padding:6px 0 6px 10px;border-left:3px solid var(--line);margin-top:6px;word-break:break-word}
1276
+ .scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
1277
+ .lanesbox{border:1px solid var(--line);border-radius:12px;background:var(--card)}
1278
+ .lanes{min-width:640px;padding:8px 12px 12px}
1279
+ .axis{position:relative;height:18px;margin-left:120px;font-size:11px;color:var(--mute)}.axis span{position:absolute;transform:translateX(-50%)}.axis span:first-child{transform:none}.axis span:last-child{transform:translateX(-100%)}
1280
+ .lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
1281
+ .lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
1282
+ .track{position:relative;flex:1;height:26px}
1283
+ .bar{position:absolute;top:3px;height:20px;border-radius:5px;background:color-mix(in srgb,var(--c) 30%,transparent);border:1px solid var(--c);overflow:hidden;font-size:11px;line-height:18px;padding:0 4px;white-space:nowrap}
1284
+ .bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
1285
+ .bar.ok{box-shadow:inset -3px 0 0 var(--ok)}.bar.bad{box-shadow:inset -3px 0 0 var(--bad)}.bar.warn{box-shadow:inset -3px 0 0 var(--warn)}
1286
+ .mark{position:absolute;top:-3px;width:7px;height:7px;margin-left:-3px;border-radius:50%;background:var(--brand)}
1287
+ .mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}
1288
+ ol,ul{padding:0;margin:0;list-style:none}
1289
+ .phase{margin-top:22px}
1290
+ .phase h3{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;font-weight:400;color:var(--mute);font-size:13px}
1291
+ .tag{font-weight:800;letter-spacing:.08em;color:#fff;background:var(--brand);border-radius:6px;padding:2px 8px;font-size:12px}
1292
+ .timeline li{display:flex;gap:10px;padding:6px 0;font-size:14px;border-top:1px solid var(--line);min-width:0}
1293
+ .timeline li>div{min-width:0;word-break:break-word}
1294
+ .timeline time{flex:none;width:46px;font-variant-numeric:tabular-nums;font-size:13px}
1295
+ .timeline .merge .text{color:var(--ok);font-weight:600}.timeline .fail .text{color:var(--bad)}.timeline .conflict .text,.timeline .qa .text,.timeline .takeover .text{color:var(--warn)}
1296
+ .timeline .progress .text,.timeline .announce .text,.timeline .team .text,.timeline .other .text,.timeline .memory .text{color:var(--mute)}
1297
+ #noise{position:absolute;opacity:0;pointer-events:none}
1298
+ .toggle{display:inline-block;font-size:13px;color:var(--mute);border:1px solid var(--line);border-radius:999px;padding:3px 12px;cursor:pointer;user-select:none;margin-top:4px}
1299
+ .toggle::before{content:"○ "}#noise:checked~.toggle::before{content:"● "}
1300
+ #noise:focus-visible~.toggle{outline:2px solid var(--brand)}
1301
+ .timeline li.noise{display:none}#noise:checked~.phases .timeline li.noise{display:flex}
1302
+ .arrow{color:var(--mute)}.detail{font-size:13px;white-space:pre-wrap;word-break:break-word}
1303
+ .plan{counter-reset:n}.plan li{padding:6px 0;border-top:1px solid var(--line);word-break:break-word}.plan li::before{counter-increment:n;content:counter(n) ". ";color:var(--mute)}
1304
+ .contract{margin:10px 0 0}
1305
+ .result li{font-size:15px}.result li.ok{color:var(--ok)}.result li.warn{color:var(--warn)}
1306
+ details{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:10px 0;padding:8px 12px}summary{cursor:pointer;font-weight:600}
1307
+ .thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
1308
+ .thread li{max-width:85%;padding:8px 10px;border-radius:12px;border:1px solid var(--line);border-left:4px solid var(--c);word-break:break-word}
1309
+ .thread li.r{align-self:flex-end;border-left:1px solid var(--line);border-right:4px solid var(--c)}.thread .meta{font-size:12px}
1310
+ table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}
1311
+ table.team td{white-space:normal;word-break:break-word}table.team th{color:var(--mute);font-weight:600;vertical-align:top}
1312
+ td.hot{font-weight:700;color:var(--brand)}td.self{color:var(--mute)}
1313
+ footer{margin-top:40px;font-size:12px;color:var(--mute);text-align:center}
1314
+ @media (max-width:520px){h1{font-size:21px}.hero{padding:16px}.kpi b,.tile b{font-size:20px}.kpis,.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
1315
+ `;
1215
1316
  export function renderHtml(s) {
1216
1317
  const r = s.run;
1217
1318
  const c = s.collab;
@@ -1370,83 +1471,7 @@ export function renderHtml(s) {
1370
1471
  <meta name="viewport" content="width=device-width, initial-scale=1">
1371
1472
  <title>Huddle story</title>
1372
1473
  <style>
1373
- :root{--bg:#f6f5f1;--fg:#1d1b18;--mute:#6f6a62;--line:#e4e0d8;--card:#fff;--soft:#efede7;--ok:#23864c;--bad:#c93f30;--warn:#a86f05;--brand:#6a5af0;color-scheme:light}
1374
- @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#121117;--fg:#ece9e3;--mute:#9a948b;--line:#2b2932;--card:#1b1a21;--soft:#23222a;--ok:#4cc27c;--bad:#f06a5b;--warn:#e0a53a;--brand:#9488ff;color-scheme:dark}}
1375
- :root[data-theme="dark"]{--bg:#121117;--fg:#ece9e3;--mute:#9a948b;--line:#2b2932;--card:#1b1a21;--soft:#23222a;--ok:#4cc27c;--bad:#f06a5b;--warn:#e0a53a;--brand:#9488ff;color-scheme:dark}
1376
- *{box-sizing:border-box}
1377
- body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
1378
- main{max-width:1100px;margin:0 auto;padding:24px 16px 60px}
1379
- h1{font-size:26px;line-height:1.25;margin:6px 0 4px;word-break:break-word}
1380
- h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--brand);margin:36px 0 12px}
1381
- h2 small{text-transform:none;letter-spacing:0}
1382
- h3{font-size:14px;margin:0 0 10px}
1383
- .brand{font-weight:700;color:var(--brand);font-size:13px;letter-spacing:.04em}
1384
- .sub,small,.detail,time,.meta,.none,.legend{color:var(--mute)}small{font-size:12px;margin-left:6px;font-weight:400}
1385
- .none{font-size:13px;margin:6px 0}
1386
- .who{display:inline-flex;align-items:center;gap:5px;font-weight:600;white-space:nowrap}.who i{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1387
- .hero{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px}
1388
- .hero details{margin:8px 0}
1389
- .kpis,.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-top:16px}
1390
- .kpi,.tile{background:var(--soft);border-radius:12px;padding:10px 12px;min-width:0}
1391
- .kpi b,.tile b{display:block;font-size:24px;line-height:1.2;font-variant-numeric:tabular-nums}
1392
- .kpi span,.tile span{font-size:12px;color:var(--mute)}
1393
- .kpi.ok b,.tile.ok b{color:var(--ok)}.kpi.bad b,.tile.bad b{color:var(--bad)}.kpi.warn b,.tile.warn b{color:var(--warn)}
1394
- .verdict{margin:14px 0 0;font-weight:600}.verdict.ok{color:var(--ok)}.verdict.warn{color:var(--warn)}
1395
- .crew{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;margin-top:12px}
1396
- .agent{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--c);border-radius:12px;padding:10px 14px;min-width:0}
1397
- .agent .an{display:flex;align-items:center;gap:7px;font-weight:700;font-size:16px}.agent .an i{width:11px;height:11px;border-radius:50%;background:var(--c)}
1398
- .agent .as{font-size:14px}.agent .am{font-size:12px;color:var(--mute);font-variant-numeric:tabular-nums}
1399
- .cards{columns:320px 3;column-gap:12px;margin-top:12px}.cards .card{break-inside:avoid;margin-bottom:12px}
1400
- .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;min-width:0}
1401
- .list li{font-size:14px;padding:7px 0;border-top:1px solid var(--line);word-break:break-word}.list li:first-child{border-top:0}
1402
- .list li.mute{color:var(--mute)}
1403
- .badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:var(--soft);color:var(--mute);white-space:nowrap}
1404
- .badge.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}.badge.bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 14%,transparent)}.badge.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 16%,transparent)}
1405
- .grid td,.grid th{text-align:center}.grid th:first-child{text-align:left}
1406
- .ad,.fl{display:inline-block;min-width:22px;padding:1px 6px;margin:1px;border-radius:6px;font-weight:700;font-size:12px}
1407
- .ad{color:var(--ok);background:color-mix(in srgb,var(--ok) 15%,transparent)}.fl{color:var(--warn);background:color-mix(in srgb,var(--warn) 17%,transparent)}.z{color:var(--line)}
1408
- .legend{font-size:12px;margin:8px 0}
1409
- .quote{font-size:13px;padding:6px 0 6px 10px;border-left:3px solid var(--line);margin-top:6px;word-break:break-word}
1410
- .scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
1411
- .lanesbox{border:1px solid var(--line);border-radius:12px;background:var(--card)}
1412
- .lanes{min-width:640px;padding:8px 12px 12px}
1413
- .axis{position:relative;height:18px;margin-left:120px;font-size:11px;color:var(--mute)}.axis span{position:absolute;transform:translateX(-50%)}.axis span:first-child{transform:none}.axis span:last-child{transform:translateX(-100%)}
1414
- .lane{display:flex;align-items:center;height:38px;border-top:1px solid var(--line)}
1415
- .lane .name{width:120px;flex:none;overflow:hidden;text-overflow:ellipsis;font-size:13px}
1416
- .track{position:relative;flex:1;height:26px}
1417
- .bar{position:absolute;top:3px;height:20px;border-radius:5px;background:color-mix(in srgb,var(--c) 30%,transparent);border:1px solid var(--c);overflow:hidden;font-size:11px;line-height:18px;padding:0 4px;white-space:nowrap}
1418
- .bar.review{border-style:dashed}.bar.conflict{border-color:var(--warn)}.bar.qa{border-style:dotted}
1419
- .bar.ok{box-shadow:inset -3px 0 0 var(--ok)}.bar.bad{box-shadow:inset -3px 0 0 var(--bad)}.bar.warn{box-shadow:inset -3px 0 0 var(--warn)}
1420
- .mark{position:absolute;top:-3px;width:7px;height:7px;margin-left:-3px;border-radius:50%;background:var(--brand)}
1421
- .mark.merge{background:var(--ok)}.mark.fail{background:var(--bad)}.mark.qa,.mark.takeover{background:var(--warn)}
1422
- ol,ul{padding:0;margin:0;list-style:none}
1423
- .phase{margin-top:22px}
1424
- .phase h3{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;font-weight:400;color:var(--mute);font-size:13px}
1425
- .tag{font-weight:800;letter-spacing:.08em;color:#fff;background:var(--brand);border-radius:6px;padding:2px 8px;font-size:12px}
1426
- .timeline li{display:flex;gap:10px;padding:6px 0;font-size:14px;border-top:1px solid var(--line);min-width:0}
1427
- .timeline li>div{min-width:0;word-break:break-word}
1428
- .timeline time{flex:none;width:46px;font-variant-numeric:tabular-nums;font-size:13px}
1429
- .timeline .merge .text{color:var(--ok);font-weight:600}.timeline .fail .text{color:var(--bad)}.timeline .conflict .text,.timeline .qa .text,.timeline .takeover .text{color:var(--warn)}
1430
- .timeline .progress .text,.timeline .announce .text,.timeline .team .text,.timeline .other .text,.timeline .memory .text{color:var(--mute)}
1431
- #noise{position:absolute;opacity:0;pointer-events:none}
1432
- .toggle{display:inline-block;font-size:13px;color:var(--mute);border:1px solid var(--line);border-radius:999px;padding:3px 12px;cursor:pointer;user-select:none;margin-top:4px}
1433
- .toggle::before{content:"○ "}#noise:checked~.toggle::before{content:"● "}
1434
- #noise:focus-visible~.toggle{outline:2px solid var(--brand)}
1435
- .timeline li.noise{display:none}#noise:checked~.phases .timeline li.noise{display:flex}
1436
- .arrow{color:var(--mute)}.detail{font-size:13px;white-space:pre-wrap;word-break:break-word}
1437
- .plan{counter-reset:n}.plan li{padding:6px 0;border-top:1px solid var(--line);word-break:break-word}.plan li::before{counter-increment:n;content:counter(n) ". ";color:var(--mute)}
1438
- .contract{margin:10px 0 0}
1439
- .result li{font-size:15px}.result li.ok{color:var(--ok)}.result li.warn{color:var(--warn)}
1440
- details{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:10px 0;padding:8px 12px}summary{cursor:pointer;font-weight:600}
1441
- .thread{display:flex;flex-direction:column;gap:8px;margin-top:10px}
1442
- .thread li{max-width:85%;padding:8px 10px;border-radius:12px;border:1px solid var(--line);border-left:4px solid var(--c);word-break:break-word}
1443
- .thread li.r{align-self:flex-end;border-left:1px solid var(--line);border-right:4px solid var(--c)}.thread .meta{font-size:12px}
1444
- table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}
1445
- table.team td{white-space:normal;word-break:break-word}table.team th{color:var(--mute);font-weight:600;vertical-align:top}
1446
- td.hot{font-weight:700;color:var(--brand)}td.self{color:var(--mute)}
1447
- footer{margin-top:40px;font-size:12px;color:var(--mute);text-align:center}
1448
- @media (max-width:520px){h1{font-size:21px}.hero{padding:16px}.kpi b,.tile b{font-size:20px}.kpis,.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
1449
- </style>
1474
+ ${STORY_CSS}</style>
1450
1475
  </head>
1451
1476
  <body>
1452
1477
  <main>
@@ -0,0 +1,176 @@
1
+ // You and the room, during a run: the agents' questions for you
2
+ // (huddle_ask_user) and what you type to them.
3
+ //
4
+ // A question shows like the ones Claude Code, Codex and Cursor ask, with
5
+ // numbered options, the agent's recommendation first:
6
+ //
7
+ // ⚑ claude asks you · should it also convert Rankine?
8
+ // 1. No, just C, F and K (recommended)
9
+ // 2. Yes, add Rankine too
10
+ // type 1-2 · Enter = 1 · or answer in your own words · picks 1 in 90s
11
+ //
12
+ // Unanswered, it goes with option 1 when the time runs out: the run never
13
+ // waits on someone who walked away. Anything else you type during a run goes
14
+ // to every working agent, sent as "you" (room/user.ts); "@codex …" to one.
15
+ import { fmtDuration } from "../adapters/usage.js";
16
+ import { C } from "./term.js";
17
+ export class Talk {
18
+ now;
19
+ queue = [];
20
+ /** A question is on screen (the first one asked; the rest wait their turn). */
21
+ onShow = () => { };
22
+ /** It was answered, by you or by the clock. */
23
+ onSettled = () => { };
24
+ /** Set by the run while it's on: what you typed for the agents, and `details`. */
25
+ say = null;
26
+ details = null;
27
+ constructor(now = () => Date.now()) {
28
+ this.now = now;
29
+ }
30
+ /** The question on screen and when it times out, if any. */
31
+ current() {
32
+ const p = this.queue[0];
33
+ return p ? { q: p.q, until: p.until } : null;
34
+ }
35
+ ask(q) {
36
+ return new Promise((resolve) => {
37
+ const p = { q, until: 0, settle: resolve, timer: undefined };
38
+ this.queue.push(p);
39
+ if (this.queue.length === 1)
40
+ this.show();
41
+ });
42
+ }
43
+ /** The time starts when a question reaches the screen, not when it was asked. */
44
+ show() {
45
+ const p = this.queue[0];
46
+ if (!p)
47
+ return;
48
+ p.until = this.now() + p.q.waitMs;
49
+ p.timer = setTimeout(() => this.settle({ choice: 1, text: "", by: "default" }), p.q.waitMs);
50
+ this.onShow(p.q, p.until);
51
+ }
52
+ settle(a) {
53
+ const p = this.queue.shift();
54
+ if (!p)
55
+ return;
56
+ clearTimeout(p.timer);
57
+ p.settle(a);
58
+ this.onSettled(p.q, a);
59
+ this.show();
60
+ }
61
+ /**
62
+ * A line you typed. With a question up, it's the answer: a number picks that
63
+ * option, Enter picks 1, anything else is your answer in your own words.
64
+ * Returns false when no question is up (the line is for someone else).
65
+ */
66
+ answer(line) {
67
+ const p = this.queue[0];
68
+ if (!p)
69
+ return false;
70
+ const t = line.trim();
71
+ const k = Number(t);
72
+ if (!t)
73
+ this.settle({ choice: 1, text: "", by: "you" });
74
+ else if (/^\d$/.test(t) && k >= 1 && k <= p.q.options.length)
75
+ this.settle({ choice: k, text: "", by: "you" });
76
+ else
77
+ this.settle({ choice: null, text: t, by: "you" });
78
+ return true;
79
+ }
80
+ /** Every open question goes with option 1 (the run ended or was stopped). */
81
+ close() {
82
+ while (this.queue.length)
83
+ this.settle({ choice: 1, text: "", by: "default" });
84
+ }
85
+ }
86
+ /** The question as it's printed. */
87
+ export function questionLines(q, until, now = Date.now()) {
88
+ const out = [` ${C.yellow("⚑")} ${C.bold(q.from)} ${C.yellow("asks you")} ${C.faint("·")} ${q.question}`];
89
+ if (q.why)
90
+ out.push(C.faint(` ${q.why}`));
91
+ q.options.forEach((o, i) => out.push(` ${C.bold(String(i + 1) + ".")} ${o}${i === 0 ? C.faint(" (recommended)") : ""}`));
92
+ out.push(C.faint(` type 1-${q.options.length} · Enter = 1 · or answer in your own words · picks 1 in ${fmtDuration(Math.max(0, until - now))}`));
93
+ return out;
94
+ }
95
+ /** " → you chose 2: Yes, add Rankine too" / " → no answer in 1m30s: claude goes with 1: …" */
96
+ export function settledLine(q, a) {
97
+ if (a.by === "default")
98
+ return C.faint(` → no answer in ${fmtDuration(q.waitMs)}: ${q.from} goes with 1 · ${q.options[0]}`);
99
+ return ` ${C.green("→")} ${a.choice ? `you chose ${a.choice} · ${q.options[a.choice - 1]}` : `you said: ${a.text}`} ${C.faint(`· sent to ${q.from}`)}`;
100
+ }
101
+ /** "@codex use 2 decimals" → to codex; anything else → everyone working. */
102
+ export function addressee(line) {
103
+ const m = /^@([\w#./-]+)[,:]?\s+([\s\S]+)$/.exec(line.trim());
104
+ return m ? { to: m[1], text: m[2].trim() } : { text: line.trim() };
105
+ }
106
+ /**
107
+ * A typing line under a one-shot run's pinned block (`huddle join` on a
108
+ * terminal), so you can answer questions and talk to the agents there too.
109
+ * Raw keys: Enter sends, Backspace and ctrl-u edit, ctrl-c stops the run.
110
+ */
111
+ export class KeyLine {
112
+ onLine;
113
+ onChange;
114
+ onInterrupt;
115
+ input;
116
+ buf = "";
117
+ on = false;
118
+ onData = (d) => this.feed(String(d));
119
+ constructor(onLine, onChange,
120
+ /** ctrl-c: raw mode keeps the terminal from sending SIGINT, so the run decides. */
121
+ onInterrupt = () => process.exit(130), input = process.stdin) {
122
+ this.onLine = onLine;
123
+ this.onChange = onChange;
124
+ this.onInterrupt = onInterrupt;
125
+ this.input = input;
126
+ }
127
+ start() {
128
+ if (this.on || !this.input.isTTY)
129
+ return this;
130
+ this.on = true;
131
+ this.input.setRawMode?.(true);
132
+ this.input.on("data", this.onData);
133
+ this.input.resume();
134
+ return this;
135
+ }
136
+ stop() {
137
+ if (!this.on)
138
+ return;
139
+ this.on = false;
140
+ this.input.off("data", this.onData);
141
+ this.input.setRawMode?.(false);
142
+ this.input.pause();
143
+ }
144
+ /** What the line shows: what you've typed so far, or how to use it. */
145
+ line() {
146
+ if (!this.on)
147
+ return "";
148
+ return this.buf
149
+ ? ` ${C.violet("›")} ${this.buf}${C.faint("▏")}`
150
+ : ` ${C.violet("›")} ${C.faint("type to talk to the agents (@claude for one) · details · ctrl-c stops")}`;
151
+ }
152
+ feed(s) {
153
+ // Arrows and other escape keys: not text.
154
+ for (const ch of s.replace(/\x1b\[[0-9;?]*[A-Za-z~]|\x1bO?./g, "")) {
155
+ if (ch === "\r" || ch === "\n") {
156
+ const l = this.buf;
157
+ this.buf = "";
158
+ this.onLine(l);
159
+ }
160
+ else if (ch === "\x7f" || ch === "\b")
161
+ this.buf = [...this.buf].slice(0, -1).join("");
162
+ else if (ch === "\x15")
163
+ this.buf = "";
164
+ else if (ch === "\x03") {
165
+ this.buf = "";
166
+ this.onInterrupt();
167
+ if (!this.on)
168
+ return;
169
+ }
170
+ else if (ch >= " ")
171
+ this.buf += ch;
172
+ }
173
+ this.onChange();
174
+ }
175
+ }
176
+ //# sourceMappingURL=talk.js.map
package/dist/ui/term.js CHANGED
@@ -137,6 +137,32 @@ export function fit(s, out = process.stdout) {
137
137
  export function pad(s, n) {
138
138
  return s + " ".repeat(Math.max(0, n - width(s)));
139
139
  }
140
+ /**
141
+ * Whether progress bars draw block characters. Off when colors are (NO_COLOR,
142
+ * or output piped to a log): there a bar is just its label, "6/9 tasks".
143
+ * Tests and samples flip it to see the bars.
144
+ */
145
+ export const BARS = { on: color, ascii: ASCII };
146
+ /**
147
+ * A progress bar exactly `w` cells wide — "█████░░░" — or "" when bars are off
148
+ * or there's no room (under 2 cells). `frac` null = unknown: a faint dotted
149
+ * track, never a made-up 0%. Anything above 0 shows at least one block and
150
+ * anything below 1 leaves at least one empty, so 1% isn't nothing and 99%
151
+ * isn't done. Legacy consoles get "#####...".
152
+ */
153
+ export function bar(frac, w, o = {}) {
154
+ w = Math.floor(w);
155
+ if (!(o.on ?? BARS.on) || !(w >= 2))
156
+ return "";
157
+ const ascii = o.ascii ?? BARS.ascii;
158
+ const [full, empty, unknown] = ascii ? ["#", ".", "-"] : ["█", "░", "·"];
159
+ if (frac === null || !Number.isFinite(frac))
160
+ return C.faint(unknown.repeat(w));
161
+ const f = o.tone === "done" ? 1 : Math.min(1, Math.max(0, frac));
162
+ const n = f <= 0 ? 0 : f >= 1 ? w : Math.min(w - 1, Math.max(1, Math.round(f * w)));
163
+ const fill = o.tone === "done" ? C.green : o.tone === "failed" ? C.red : C.violet;
164
+ return (n ? fill(full.repeat(n)) : "") + (n < w ? C.faint(empty.repeat(w - n)) : "");
165
+ }
140
166
  /** Rounded box with a colored border, Claude-Code style. */
141
167
  export function box(lines, opts = {}) {
142
168
  const border = opts.border ?? C.violet;
@@ -232,6 +258,11 @@ export class Dock {
232
258
  this.timer.unref?.();
233
259
  return this;
234
260
  }
261
+ /** Repaint now (something it shows changed, e.g. a key was typed). */
262
+ redraw() {
263
+ if (this.up)
264
+ this.out.write(this.clear() + this.paint());
265
+ }
235
266
  log(line) {
236
267
  if (!this.up)
237
268
  return void this.out.write(line + "\n");