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.
@@ -11,7 +11,7 @@
11
11
  // It only reads: the Room's board and memory, the run's live stats, and the
12
12
  // activity feed. Pressing q hands the terminal back to the prompt. Every row
13
13
  // is cut to the screen's width — a row that wraps scrolls the whole frame.
14
- import { C, dot, pad, truncate, width, AGENT_COLOR } from "./term.js";
14
+ import { BARS, C, bar, dot, pad, truncate, width, AGENT_COLOR } from "./term.js";
15
15
  import { fmtDuration, fmtTokens } from "../adapters/usage.js";
16
16
  import { taskViews } from "../room/progress.js";
17
17
  import { getContract } from "../room/contract.js";
@@ -53,6 +53,76 @@ export function runPhase(db, busy) {
53
53
  return "DONE";
54
54
  return phase;
55
55
  }
56
+ /** QA's own "QA round 2: check the build" rows: checks, not work to merge. */
57
+ export const isQaCheck = (t) => /^QA round \d+:/.test(t.title) && /"qa"/.test(t.prefers ?? "");
58
+ /** Merged / failed / all of the board's work tasks. */
59
+ export function taskCounts(tasks) {
60
+ const work = tasks.filter((t) => !isQaCheck(t));
61
+ return { done: work.filter((t) => t.status === "merged").length, total: work.length, failed: work.filter((t) => t.status === "failed").length };
62
+ }
63
+ const COUNCIL_STEP = { "council.proposal": 1, "council.critique": 2, "council.adopt": 2, "council.flaw": 2, "council.ratify": 4 };
64
+ /** The run's progress from the Room alone (huddle view, the ctrl-t view). */
65
+ export function runProgress(db, busy) {
66
+ const phase = runPhase(db, busy);
67
+ if (!db || phase === "WAITING")
68
+ return { phase, done: 0, total: 0, failed: 0 };
69
+ try {
70
+ const since = (db.currentSession()?.created_at ?? 1) - 1;
71
+ const p = { phase, ...taskCounts(db.listTasks()) };
72
+ if (phase === "COUNCIL")
73
+ p.step = COUNCIL_STEP[db.eventsOfType(Object.keys(COUNCIL_STEP), since, 1).at(-1)?.type ?? ""] ?? 1;
74
+ const rounds = db.eventsOfType(["qa.round"], since, 50).length;
75
+ if (rounds)
76
+ p.round = rounds;
77
+ return p;
78
+ }
79
+ catch {
80
+ return { phase, done: 0, total: 0, failed: 0 }; // a closed db
81
+ }
82
+ }
83
+ /**
84
+ * The overall run bar, one line of at most `W` cells:
85
+ * BUILD ███████░░░░░ 6/9 tasks · 12m03s
86
+ * COUNCIL ██████░░░░ step 3/4 · 2m10s
87
+ * QA ████████████░ round 2 · 9/10 tasks · 1 failed · 31m
88
+ * ✓ DONE █████████████ 10/10 tasks · 41m
89
+ * The bar gives way first, then the word "tasks"; with bars off (NO_COLOR,
90
+ * piped) it's the words alone: "BUILD 6/9 tasks · 12m03s". "" while waiting.
91
+ */
92
+ export function progressLine(p, ms, W, o = {}) {
93
+ if (p.phase === "WAITING")
94
+ return "";
95
+ const done = p.phase === "DONE";
96
+ const head = done ? (p.failed ? C.yellow("◎ DONE") : C.green("✓ DONE")) : C.bold(C.violet(p.phase));
97
+ const frac = p.phase === "COUNCIL" ? (p.step ?? 1) / 4 : p.total ? p.done / p.total : done ? 1 : null;
98
+ const tasks = (word) => (p.total ? `${p.done}/${p.total}${word ? " tasks" : ""}` : p.phase === "PLAN" ? "planning" : "");
99
+ const label = (word) => p.phase === "COUNCIL" ? `step ${p.step ?? 1}/4` : [p.phase === "QA" && p.round ? `round ${p.round}` : "", tasks(word)].filter(Boolean).join(" · ");
100
+ const text = (word) => [label(word), p.failed ? C.red(`${p.failed} failed`) : "", ms === null ? "" : C.faint(fmtDuration(ms))].filter(Boolean).join(C.faint(" · "));
101
+ const tone = done ? (p.failed ? "failed" : "done") : "run";
102
+ for (const word of [true, false]) {
103
+ const w = Math.min(o.barMax ?? 24, W - width(head) - width(text(word)) - 2);
104
+ if (w >= 4 && (o.on ?? BARS.on))
105
+ return `${head} ${bar(frac, w, { tone, on: o.on })} ${text(word)}`;
106
+ }
107
+ return truncate(`${head} ${text(W >= 40)}`, W);
108
+ }
109
+ /**
110
+ * A task's mini bar and its number, the same everywhere: "████░░░░ 60%";
111
+ * never reported "········ —"; merged a full green bar and ✓; failed red.
112
+ * With bars off it's the number alone ("60%", "—", "✓").
113
+ */
114
+ export function taskMeter(pct, w, status) {
115
+ const tone = status === "merged" ? "done" : status === "failed" ? "failed" : "run";
116
+ const num = status === "merged" ? C.green(" ✓") : pct === null ? C.faint(" —") : (status === "failed" ? C.red : C.violet)(`${String(Math.round(pct)).padStart(3)}%`);
117
+ const b = bar(pct === null ? null : pct / 100, w, { tone });
118
+ return b ? `${b} ${num}` : num;
119
+ }
120
+ /** "est 5m" for a task with an estimate, yellow "over est 5m" once it runs past it; "" with none. */
121
+ export function estHint(estS, ranMs) {
122
+ if (!estS || estS <= 0)
123
+ return "";
124
+ return ranMs > estS * 1000 ? C.yellow(`over est ${fmtDuration(estS * 1000)}`) : C.faint(`est ${fmtDuration(estS * 1000)}`);
125
+ }
56
126
  const hexColor = (id) => C.hex(AGENT_COLOR[id.split("/").pop().split("#")[0]] ?? "#8A857C");
57
127
  /** The shared screen's 📎/📌 rows in the view's glyphs (emoji are two cells wide and vary by font). */
58
128
  const screenGlyphs = (l) => l.replace(/^📎/u, C.violet("+")).replace(/^📌/u, C.violet("◆"));
@@ -133,7 +203,6 @@ export class Dashboard {
133
203
  const db = this.src.db();
134
204
  const stats = this.src.stats();
135
205
  const busy = !!stats && [...stats.agents.values()].some((a) => a.busySince);
136
- const phase = runPhase(db, busy);
137
206
  let goal = "";
138
207
  try {
139
208
  goal = db?.currentSession()?.goal ?? "";
@@ -144,19 +213,20 @@ export class Dashboard {
144
213
  const tabs = ["agents", "plan", "board"]
145
214
  .map((t, i) => (t === tab ? C.bold(C.violet(`[${i + 1} ${t}]`)) : C.faint(` ${i + 1} ${t} `)))
146
215
  .join(" ");
147
- const clock = stats ? `${C.faint("⏱")} ${fmtDuration((stats.endedAt ?? now) - stats.startedAt)}` : "";
148
- const state = phase === "DONE"
149
- ? C.green("✓ done")
150
- : phase === "WAITING"
151
- ? C.faint("○ waiting for a run")
152
- : `${C.bold(C.violet(phase))} ${C.faint("·")} ${busy ? C.green("● live") : C.faint("○ between steps")}`;
153
- const right = [state, clock].filter(Boolean).join(C.faint(" · "));
154
- const bar = ` ${C.violet("⬢")} ${C.bold("huddle")} ${tabs}`;
216
+ // The overall bar: the run's own live numbers when it's this process's run, else the Room's.
217
+ const prog = stats?.run ?? runProgress(db, busy);
218
+ const ms = stats ? (stats.endedAt ?? now) - stats.startedAt : null;
219
+ // On a narrow screen the live chip is just its dot: the bar needs the room.
220
+ const chip = prog.phase === "DONE" || prog.phase === "WAITING" ? "" : busy ? C.green(W >= 100 ? "● live" : "●") : C.faint(W >= 100 ? "○ between steps" : "○");
221
+ const head = ` ${C.violet("⬢")} ${C.bold("huddle")} ${tabs}`;
222
+ const right = prog.phase === "WAITING"
223
+ ? C.faint("○ waiting for a run")
224
+ : [progressLine(prog, ms, Math.max(12, W - width(head) - width(chip) - 5), { barMax: W >= 110 ? 12 : 10 }), chip].filter(Boolean).join(C.faint(" · "));
155
225
  // The goal takes whatever room is left between the tabs and the status.
156
- const room = W - width(bar) - width(right) - 4;
226
+ const room = W - width(head) - width(right) - 4;
157
227
  const goalS = goal && room >= 12 ? C.faint(truncate(goal.replace(/\s+/g, " "), room)) : "";
158
- const gap = Math.max(1, W - width(bar) - width(goalS) - width(right) - 3);
159
- const top = goalS ? `${bar} ${goalS}${" ".repeat(gap)}${right}` : `${bar}${" ".repeat(Math.max(1, W - width(bar) - width(right) - 1))}${right}`;
228
+ const gap = Math.max(1, W - width(head) - width(goalS) - width(right) - 3);
229
+ const top = goalS ? `${head} ${goalS}${" ".repeat(gap)}${right}` : `${head}${" ".repeat(Math.max(1, W - width(head) - width(right) - 1))}${right}`;
160
230
  const bodyH = H - 4;
161
231
  const body = tab === "agents" ? this.agents(W, bodyH, stats, db, now) : tab === "plan" ? this.plan(W, bodyH, db) : this.board(W, bodyH, db);
162
232
  const lastRoom = [...this.feed].reverse().find((a) => !a.agent);
@@ -174,8 +244,10 @@ export class Dashboard {
174
244
  if (!ids.length)
175
245
  return ["", C.faint(` ${this.src.empty ?? "no agents working — give Huddle a goal"}`)];
176
246
  let views = [];
247
+ let ests = new Map();
177
248
  try {
178
249
  views = db ? taskViews(db, now) : [];
250
+ ests = new Map((db?.listTasks() ?? []).map((t) => [t.id, t.est_seconds]));
179
251
  }
180
252
  catch {
181
253
  /* a closed db: no pinned task line */
@@ -186,13 +258,13 @@ export class Dashboard {
186
258
  const paneH = Math.max(4, Math.floor(H / rows));
187
259
  const out = [];
188
260
  for (let r = 0; r < rows; r++) {
189
- const panes = ids.slice(r * cols, r * cols + cols).map((id) => this.pane(id, paneW, paneH, stats?.agents.get(id), views, now));
261
+ const panes = ids.slice(r * cols, r * cols + cols).map((id) => this.pane(id, paneW, paneH, stats?.agents.get(id), views, now, ests));
190
262
  for (let i = 0; i < paneH; i++)
191
263
  out.push(panes.map((p) => p[i] ?? " ".repeat(paneW)).join(""));
192
264
  }
193
265
  return out;
194
266
  }
195
- pane(id, w, h, a, views, now) {
267
+ pane(id, w, h, a, views, now, ests) {
196
268
  const color = hexColor(id);
197
269
  const status = a?.busySince
198
270
  ? `${C.violet("working")} ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? " · " + fmtTokens(a.liveTokens) + " tok" : ""}`
@@ -206,9 +278,10 @@ export class Dashboard {
206
278
  const on = a?.busySince ? views.find((v) => v.owner === id && (v.status === "claimed" || v.status === "in_progress")) : undefined;
207
279
  const pinned = [];
208
280
  if (on) {
209
- const pct = on.reported ? C.violet(`${on.progress_pct}%`) : C.faint("no report yet");
210
- const said = on.last_note ? C.faint(` · note ${fmtDuration(on.last_update_s * 1000)} ago`) : "";
211
- const tail = ` ${pct}${said}`;
281
+ const meter = taskMeter(on.reported ? on.progress_pct : null, inner >= 70 ? 10 : 6);
282
+ const said = on.reported ? (on.last_note ? C.faint(` · note ${fmtDuration(on.last_update_s * 1000)} ago`) : "") : C.faint(" · no report yet");
283
+ const est = estHint(ests.get(on.id), (on.age_s ?? 0) * 1000);
284
+ const tail = ` ${meter}${said}${est ? C.faint(" · ") + est : ""}`;
212
285
  pinned.push(`${C.violet("▸")} ${C.bold(truncate(on.title, Math.max(8, inner - 4 - width(tail))))}${tail}`);
213
286
  }
214
287
  const room = h - 2 - pinned.length;
@@ -269,12 +342,10 @@ export class Dashboard {
269
342
  }
270
343
  const bw = W >= 100 ? 14 : 8;
271
344
  for (const t of views) {
272
- const filled = Math.round((t.progress_pct / 100) * bw);
273
- const barS = t.status === "merged" ? C.green("█".repeat(bw)) : C.violet("█".repeat(filled)) + C.faint("░".repeat(bw - filled));
274
345
  // Never reported (no huddle_progress yet): say so instead of showing a made-up 0%.
275
346
  const silent = ["claimed", "in_progress"].includes(t.status) && !t.reported;
276
347
  const who = t.owner ? ` ${dot(t.owner)} ${C.faint(t.owner)}` : "";
277
- left.push(` ${mark(t.status, !!t.owner)} ${barS} ${silent ? C.faint(" — ") : String(t.progress_pct).padStart(3) + "%"} ${t.title}${who}`);
348
+ left.push(` ${mark(t.status, !!t.owner)} ${taskMeter(silent ? null : t.progress_pct, bw, t.status)} ${t.title}${who}`);
278
349
  }
279
350
  if (!views.length)
280
351
  left.push(C.faint(" no tasks yet"));