@multiclawcli/dashboard 0.3.1 → 0.4.0

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/dist/tui.js ADDED
@@ -0,0 +1,1548 @@
1
+ // src/tui/index.ts
2
+ import { render } from "ink";
3
+ import { createElement } from "react";
4
+
5
+ // src/tui/App.tsx
6
+ import * as path from "path";
7
+ import { isAbortError, Orchestrator } from "@multiclawcli/core";
8
+ import { Box as Box5, Text as Text5, useApp, useInput, useWindowSize } from "ink";
9
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
10
+
11
+ // src/tui/commands.ts
12
+ var COMMANDS = [
13
+ { name: "agents", description: "List the configured team", availability: "always" },
14
+ {
15
+ name: "focus",
16
+ args: "<id>",
17
+ description: "Focus an agent by id",
18
+ availability: "always"
19
+ },
20
+ { name: "clear", description: "Clear the focused agent's log", availability: "always" },
21
+ {
22
+ name: "history",
23
+ description: "Browse finished turns",
24
+ availability: "always"
25
+ },
26
+ {
27
+ name: "cancel",
28
+ description: "Cancel the running turn and its queue",
29
+ availability: "running"
30
+ },
31
+ { name: "help", description: "Show the key map", availability: "always" },
32
+ { name: "quit", description: "Exit the console", availability: "always" }
33
+ ];
34
+ function availableCommands(running) {
35
+ const phase = running ? "running" : "idle";
36
+ return COMMANDS.filter((c) => c.availability === "always" || c.availability === phase);
37
+ }
38
+ function matchCommands(query, running) {
39
+ const q = query.trim().toLowerCase();
40
+ const pool = availableCommands(running);
41
+ if (!q) return pool;
42
+ const byName = [];
43
+ const byDescription = [];
44
+ for (const command of pool) {
45
+ if (command.name.startsWith(q)) byName.push(command);
46
+ else if (command.description.toLowerCase().includes(q)) byDescription.push(command);
47
+ }
48
+ return [...byName, ...byDescription];
49
+ }
50
+ function isCommandLine(text) {
51
+ return text.startsWith("/");
52
+ }
53
+ function menuQuery(text) {
54
+ if (!isCommandLine(text)) return void 0;
55
+ const rest = text.slice(1);
56
+ return /\s/.test(rest) ? void 0 : rest;
57
+ }
58
+ function parseCommand(text) {
59
+ if (!isCommandLine(text)) return void 0;
60
+ const [name = "", ...args] = text.slice(1).trim().split(/\s+/);
61
+ return name ? { name, args } : void 0;
62
+ }
63
+
64
+ // src/tui/components/chrome.tsx
65
+ import { Box, Text } from "ink";
66
+
67
+ // src/tui/format.ts
68
+ function truncate(text, max) {
69
+ if (max <= 0) return "";
70
+ if (text.length <= max) return text;
71
+ return `${text.slice(0, Math.max(0, max - 1))}\u2026`;
72
+ }
73
+ function formatDuration(ms) {
74
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
75
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
76
+ const totalSeconds = Math.round(ms / 1e3);
77
+ const minutes = Math.floor(totalSeconds / 60);
78
+ if (minutes < 60) return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
79
+ const hours = Math.floor(minutes / 60);
80
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
81
+ }
82
+ var CIRCLED = "\u2460\u2461\u2462\u2463\u2464\u2465\u2466\u2467\u2468\u2469\u246A\u246B\u246C\u246D\u246E\u246F\u2470\u2471\u2472\u2473";
83
+ function circled(n) {
84
+ return CIRCLED[n - 1] ?? `(${n})`;
85
+ }
86
+ function padRight(text, width) {
87
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
88
+ }
89
+
90
+ // src/tui/layout.ts
91
+ var MIN_COLUMNS = 40;
92
+ var MIN_ROWS = 10;
93
+ var COMPACT_COLUMNS = 72;
94
+ var RAIL_MIN_ROWS = 16;
95
+ var MAX_RAIL_ROWS = 2;
96
+ var MAX_MENU_ROWS = 7;
97
+ var MAX_QUEUE_ROWS = 3;
98
+ var MIN_BODY_ROWS = 3;
99
+ function measureRailRows(cells, columns) {
100
+ if (!cells || cells.length === 0) return 0;
101
+ if (columns <= 0) return MAX_RAIL_ROWS;
102
+ let rows = 1;
103
+ let used = 0;
104
+ for (const cell of cells) {
105
+ if (used > 0 && used + cell > columns) {
106
+ rows += 1;
107
+ if (rows >= MAX_RAIL_ROWS) return MAX_RAIL_ROWS;
108
+ used = cell;
109
+ } else {
110
+ used += cell;
111
+ }
112
+ }
113
+ return rows;
114
+ }
115
+ function computeLayout(input) {
116
+ const columns = Math.max(MIN_COLUMNS, input.columns);
117
+ const rows = Math.max(MIN_ROWS, input.rows);
118
+ const compact = columns < COMPACT_COLUMNS;
119
+ const welcome = !input.overlay && input.turn === 0;
120
+ const showRail = !welcome && !input.overlay && rows >= RAIL_MIN_ROWS && input.hasRail;
121
+ const menuRows = Math.min(Math.max(0, input.menuRows), MAX_MENU_ROWS);
122
+ const queueRows = Math.min(Math.max(0, input.queueRows ?? 0), MAX_QUEUE_ROWS);
123
+ const railRows = showRail ? Math.min(MAX_RAIL_ROWS, Math.max(1, measureRailRows(input.railCells, columns))) : 0;
124
+ const reserved = 1 + // header
125
+ 1 + // rule under the header
126
+ (railRows > 0 ? railRows + 1 : 0) + // pipeline rail plus the gap above it
127
+ 1 + // gap before the body
128
+ (input.notice ? 1 : 0) + (input.viewing ? 1 : 0) + // "viewing turn N" banner
129
+ queueRows + menuRows + 1 + // prompt
130
+ 1;
131
+ const bodyHeight = Math.max(MIN_BODY_ROWS, rows - reserved);
132
+ return {
133
+ columns,
134
+ rows,
135
+ compact,
136
+ showTeam: !compact,
137
+ showRail,
138
+ railRows,
139
+ welcome,
140
+ bodyHeight,
141
+ // The stream spends one of the body's rows on its own header.
142
+ logViewport: Math.max(1, bodyHeight - 1),
143
+ teamWidth: Math.min(30, Math.max(18, Math.floor(columns * 0.24))),
144
+ menuRows,
145
+ queueRows
146
+ };
147
+ }
148
+
149
+ // src/tui/theme.ts
150
+ var COLOR = {
151
+ accent: "cyan",
152
+ ok: "green",
153
+ warn: "yellow",
154
+ error: "red",
155
+ muted: "gray"
156
+ };
157
+ var STATUS = {
158
+ pending: { glyph: "\u25CB", color: COLOR.muted, dim: true, label: "queued" },
159
+ running: { glyph: "\u25D0", color: COLOR.accent, label: "running" },
160
+ retrying: { glyph: "\u21BB", color: COLOR.warn, label: "retrying" },
161
+ success: { glyph: "\u2713", color: COLOR.ok, label: "done" },
162
+ failed: { glyph: "\u2717", color: COLOR.error, label: "failed" },
163
+ skipped: { glyph: "\u2298", color: COLOR.muted, dim: true, label: "skipped" }
164
+ };
165
+ var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
166
+ var GLYPH = {
167
+ /** Marks the console itself in the header and the welcome screen. */
168
+ brand: "\u25C6",
169
+ /** The leader runs before the pipeline exists, so it gets its own mark. */
170
+ leader: "\u25C6",
171
+ agent: "\u25C7",
172
+ /** Points at the focused row in the team list. */
173
+ caret: "\u203A",
174
+ /** Pipeline flow between stages. */
175
+ connector: "\u2500\u2500\u25B8",
176
+ /** The stream is tailing; new output appears as it arrives. */
177
+ live: "\u25BC",
178
+ /** The stream is parked while the user reads back. */
179
+ paused: "\u23F8",
180
+ /**
181
+ * A follow-up waiting for the team to finish.
182
+ *
183
+ * Latin-1 on purpose: it renders as exactly one cell in every terminal, which
184
+ * a pictograph like ⏳ does not.
185
+ */
186
+ queued: "\xBB",
187
+ /** The leader is asking for a decision rather than reporting one. */
188
+ ask: "?",
189
+ /** A mid-turn warning that rides alongside the outcome rather than hiding it. */
190
+ warn: "\u26A0"
191
+ };
192
+
193
+ // src/tui/components/chrome.tsx
194
+ import { jsx, jsxs } from "react/jsx-runtime";
195
+ function Header({ name, right }) {
196
+ return /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [
197
+ /* @__PURE__ */ jsx(Box, { flexShrink: 1, children: /* @__PURE__ */ jsxs(Text, { wrap: "truncate-end", children: [
198
+ /* @__PURE__ */ jsxs(Text, { color: COLOR.accent, bold: true, children: [
199
+ GLYPH.brand,
200
+ " multiclaw"
201
+ ] }),
202
+ name ? /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
203
+ " \xB7 ",
204
+ name
205
+ ] }) : null
206
+ ] }) }),
207
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: right }) })
208
+ ] });
209
+ }
210
+ function Rule({ width }) {
211
+ return /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: COLOR.muted, dimColor: true, children: "\u2500".repeat(Math.max(0, width)) }) });
212
+ }
213
+ function Chip({ agent, fallbackName, spinner }) {
214
+ const status = agent?.status ?? "pending";
215
+ const style = STATUS[status];
216
+ const glyph = status === "running" ? spinner : style.glyph;
217
+ return /* @__PURE__ */ jsx(Box, { marginRight: 1, children: /* @__PURE__ */ jsxs(Text, { color: style.color, dimColor: style.dim, children: [
218
+ glyph,
219
+ " ",
220
+ agent?.icon ? `${agent.icon} ` : "",
221
+ agent?.name ?? fallbackName
222
+ ] }) });
223
+ }
224
+ function railCellWidths(turn) {
225
+ const chip = (name, icon) => `G ${icon ? `${icon} ` : ""}${name}`.length + 1;
226
+ const cells = [];
227
+ for (const id of turn.leaderIds) {
228
+ const agent = turn.agents[id];
229
+ cells.push(chip(agent?.name ?? id, agent?.icon));
230
+ }
231
+ turn.stages.forEach((stage, index) => {
232
+ cells.push(` ${GLYPH.connector} `.length, `${circled(index + 1)} `.length);
233
+ for (const agent of stage.agents) {
234
+ cells.push(chip(agent.name, agent.icon));
235
+ }
236
+ });
237
+ return cells;
238
+ }
239
+ function PipelineRail({ turn, spinner }) {
240
+ if (turn.leaderIds.length === 0 && turn.stages.length === 0) return null;
241
+ const items = [];
242
+ for (const id of turn.leaderIds) {
243
+ items.push(
244
+ /* @__PURE__ */ jsx(Chip, { agent: turn.agents[id], fallbackName: id, spinner }, `leader-${id}`)
245
+ );
246
+ }
247
+ turn.stages.forEach((stage, index) => {
248
+ items.push(
249
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${GLYPH.connector} ` }, `flow-${stage.stageIndex}`),
250
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
251
+ circled(index + 1),
252
+ " "
253
+ ] }, `stage-${stage.stageIndex}`)
254
+ );
255
+ for (const agent of stage.agents) {
256
+ items.push(
257
+ /* @__PURE__ */ jsx(
258
+ Chip,
259
+ {
260
+ agent: turn.agents[agent.id],
261
+ fallbackName: agent.name,
262
+ spinner
263
+ },
264
+ agent.id
265
+ )
266
+ );
267
+ }
268
+ });
269
+ return /* @__PURE__ */ jsx(Box, { flexWrap: "wrap", children: items });
270
+ }
271
+ function HintBar({ status, keys }) {
272
+ return /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [
273
+ /* @__PURE__ */ jsx(Box, { flexShrink: 1, children: status }),
274
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: keys }) })
275
+ ] });
276
+ }
277
+ function QueueStrip({ items, max = MAX_QUEUE_ROWS }) {
278
+ if (items.length === 0 || max <= 0) return null;
279
+ const shown = items.slice(0, max);
280
+ const hidden = items.length - shown.length;
281
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", flexShrink: 0, children: shown.map((text, index) => (
282
+ // biome-ignore lint/suspicious/noArrayIndexKey: a queue may hold the same sentence twice, so position is what tells the rows apart
283
+ /* @__PURE__ */ jsxs(Text, { wrap: "truncate-end", children: [
284
+ /* @__PURE__ */ jsx(Text, { color: COLOR.warn, children: index === 0 ? `${GLYPH.queued} ` : " " }),
285
+ /* @__PURE__ */ jsx(Text, { dimColor: index > 0, children: text }),
286
+ hidden > 0 && index === shown.length - 1 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` (+${hidden} more)` }) : null
287
+ ] }, `${index}-${text}`)
288
+ )) });
289
+ }
290
+
291
+ // src/tui/components/overlays.tsx
292
+ import { Box as Box2, Text as Text2 } from "ink";
293
+
294
+ // src/tui/state.ts
295
+ var MAX_LOG_CHARS = 8e3;
296
+ var ANSI_PATTERN = /\x1b\[[0-9;]*[a-zA-Z]/g;
297
+ var MAX_HISTORY = 20;
298
+ var initialState = {
299
+ phase: "idle",
300
+ name: "",
301
+ turn: 0,
302
+ stages: [],
303
+ agents: {},
304
+ leaderIds: [],
305
+ autoFollow: true,
306
+ scroll: 0,
307
+ history: []
308
+ };
309
+ function stripAnsi(text) {
310
+ return text.replace(ANSI_PATTERN, "");
311
+ }
312
+ function appendLog(previous, chunk) {
313
+ const next = previous + stripAnsi(chunk);
314
+ return next.length > MAX_LOG_CHARS ? next.slice(next.length - MAX_LOG_CHARS) : next;
315
+ }
316
+ function blankAgent(id, name, patch = {}) {
317
+ return { id, name, status: "pending", logs: "", ...patch };
318
+ }
319
+ function epoch(iso) {
320
+ const value = Date.parse(iso);
321
+ return Number.isNaN(value) ? void 0 : value;
322
+ }
323
+ function agentModel(agent) {
324
+ return agent.model ?? agent.models?.[agent.runtime ?? "acp"];
325
+ }
326
+ function activeLeader(config) {
327
+ return config.useLeader === false ? void 0 : config.leader;
328
+ }
329
+ function plannedAgents(config) {
330
+ const leader = activeLeader(config);
331
+ return leader ? [leader, ...config.agents] : config.agents;
332
+ }
333
+ function leaderAgentIds(config) {
334
+ const leader = activeLeader(config);
335
+ return leader ? [leader.id] : [];
336
+ }
337
+ function seedAgents(config) {
338
+ const agents = {};
339
+ for (const agent of plannedAgents(config)) {
340
+ agents[agent.id] = blankAgent(agent.id, agent.name, {
341
+ icon: agent.icon,
342
+ model: agentModel(agent),
343
+ description: agent.description,
344
+ taskTitle: agent.taskTitle
345
+ });
346
+ }
347
+ return agents;
348
+ }
349
+ function createInitialState(config) {
350
+ const agents = seedAgents(config);
351
+ return {
352
+ phase: "idle",
353
+ name: config.name,
354
+ turn: 0,
355
+ stages: [],
356
+ leaderIds: leaderAgentIds(config),
357
+ agents,
358
+ focusId: Object.keys(agents)[0],
359
+ autoFollow: true,
360
+ scroll: 0,
361
+ history: []
362
+ };
363
+ }
364
+ function mergeStageAgents(agents, stages) {
365
+ const next = { ...agents };
366
+ for (const stage of stages) {
367
+ for (const a of stage.agents) {
368
+ const existing = next[a.id];
369
+ next[a.id] = existing ? {
370
+ ...existing,
371
+ name: a.name,
372
+ icon: a.icon,
373
+ model: a.model,
374
+ // The event's `description` is absent for a hand-rolled agent
375
+ // definition, so fall back rather than blanking a known role.
376
+ description: a.description ?? existing.description,
377
+ taskTitle: a.taskTitle
378
+ } : blankAgent(a.id, a.name, {
379
+ icon: a.icon,
380
+ model: a.model,
381
+ description: a.description,
382
+ taskTitle: a.taskTitle
383
+ });
384
+ }
385
+ }
386
+ return next;
387
+ }
388
+ function reduceEvent(state, event) {
389
+ switch (event.type) {
390
+ case "orchestration:start": {
391
+ return {
392
+ ...state,
393
+ phase: "running",
394
+ name: event.payload.name,
395
+ requirement: event.payload.requirement ?? state.requirement,
396
+ stages: event.payload.stages,
397
+ agents: mergeStageAgents(state.agents, event.payload.stages)
398
+ };
399
+ }
400
+ // The architect's task plan is applied *after* the first stage, which can
401
+ // regroup the team. The console drew the pre-plan grouping from
402
+ // `orchestration:start`; this is how it learns the grouping that ran.
403
+ case "orchestration:stages": {
404
+ return {
405
+ ...state,
406
+ stages: event.payload.stages,
407
+ agents: mergeStageAgents(state.agents, event.payload.stages)
408
+ };
409
+ }
410
+ // Stage boundaries carry no state of their own — the per-agent statuses
411
+ // derived from them already drive the pipeline bar.
412
+ case "stage:start":
413
+ case "stage:complete":
414
+ return state;
415
+ case "agent:start": {
416
+ const { agentId, agentName, icon, model, taskTitle } = event.payload;
417
+ const existing = state.agents[agentId];
418
+ const startedAt = epoch(event.timestamp);
419
+ const next = existing ? {
420
+ ...existing,
421
+ name: agentName,
422
+ icon: icon ?? existing.icon,
423
+ model: model ?? existing.model,
424
+ taskTitle: taskTitle ?? existing.taskTitle,
425
+ status: "running",
426
+ startedAt: startedAt ?? existing.startedAt
427
+ } : blankAgent(agentId, agentName, {
428
+ icon,
429
+ model,
430
+ taskTitle,
431
+ status: "running",
432
+ startedAt
433
+ });
434
+ const takesFocus = state.autoFollow;
435
+ return {
436
+ ...state,
437
+ agents: { ...state.agents, [agentId]: next },
438
+ focusId: takesFocus ? agentId : state.focusId,
439
+ // A new agent takes the stream with it, so the window must follow.
440
+ scroll: takesFocus ? 0 : state.scroll
441
+ };
442
+ }
443
+ case "agent:output": {
444
+ const { agentId, chunk } = event.payload;
445
+ const existing = state.agents[agentId];
446
+ if (!existing) return state;
447
+ return {
448
+ ...state,
449
+ agents: {
450
+ ...state.agents,
451
+ [agentId]: { ...existing, logs: appendLog(existing.logs, chunk) }
452
+ }
453
+ };
454
+ }
455
+ case "agent:complete":
456
+ case "agent:failed": {
457
+ const result = event.payload;
458
+ const status = event.type === "agent:complete" ? "success" : "failed";
459
+ const existing = state.agents[result.agentId];
460
+ const next = existing ? {
461
+ ...existing,
462
+ status,
463
+ duration: result.duration,
464
+ attempts: result.attempts,
465
+ error: result.error
466
+ } : blankAgent(result.agentId, result.agentName, {
467
+ status,
468
+ duration: result.duration,
469
+ attempts: result.attempts,
470
+ error: result.error
471
+ });
472
+ return { ...state, agents: { ...state.agents, [result.agentId]: next } };
473
+ }
474
+ case "agent:skipped": {
475
+ const { agentId, agentName, icon } = event.payload;
476
+ const existing = state.agents[agentId];
477
+ const next = existing ? { ...existing, status: "skipped" } : blankAgent(agentId, agentName, { icon, status: "skipped" });
478
+ return { ...state, agents: { ...state.agents, [agentId]: next } };
479
+ }
480
+ case "agent:retrying": {
481
+ const { agentId, agentName, attempt, maxAttempts, error } = event.payload;
482
+ const note = `
483
+ [retry ${attempt}/${maxAttempts}] ${error}
484
+ `;
485
+ const existing = state.agents[agentId];
486
+ const retry = { attempt, max: maxAttempts };
487
+ const next = existing ? { ...existing, status: "retrying", retry, logs: appendLog(existing.logs, note) } : blankAgent(agentId, agentName, { status: "retrying", retry, logs: note });
488
+ return { ...state, agents: { ...state.agents, [agentId]: next } };
489
+ }
490
+ case "orchestration:warning":
491
+ return { ...state, warning: event.payload.message };
492
+ case "orchestration:complete":
493
+ return {
494
+ ...state,
495
+ phase: "complete",
496
+ success: event.payload.success,
497
+ totalDuration: event.payload.totalDuration,
498
+ endedAt: epoch(event.timestamp) ?? Date.now(),
499
+ reply: event.payload.reply
500
+ };
501
+ default:
502
+ return state;
503
+ }
504
+ }
505
+ function focusedLineCount(turn) {
506
+ const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
507
+ return agent?.logs ? agent.logs.split("\n").length : 0;
508
+ }
509
+ function snapshot(state) {
510
+ return {
511
+ turn: state.turn,
512
+ requirement: state.requirement,
513
+ phase: state.phase,
514
+ startedAt: state.startedAt,
515
+ endedAt: state.endedAt,
516
+ stages: state.stages,
517
+ agents: state.agents,
518
+ leaderIds: state.leaderIds,
519
+ focusId: state.focusId,
520
+ autoFollow: state.autoFollow,
521
+ scroll: state.scroll,
522
+ success: state.success,
523
+ totalDuration: state.totalDuration,
524
+ warning: state.warning,
525
+ reply: state.reply
526
+ };
527
+ }
528
+ function mapShown(state, fn) {
529
+ if (state.view) {
530
+ const next2 = fn(state.view);
531
+ return next2 === state.view ? state : { ...state, view: next2 };
532
+ }
533
+ const next = fn(state);
534
+ return next === state ? state : { ...state, ...next };
535
+ }
536
+ function conversationTurns(state) {
537
+ const turns = [];
538
+ for (const turn of [...state.history, snapshot(state)]) {
539
+ if (turn.phase !== "complete" || !turn.requirement) continue;
540
+ turns.push(
541
+ turn.reply ? { requirement: turn.requirement, reply: turn.reply } : { requirement: turn.requirement }
542
+ );
543
+ }
544
+ return turns;
545
+ }
546
+ function tuiReducer(state, action) {
547
+ switch (action.type) {
548
+ case "event":
549
+ return reduceEvent(state, action.event);
550
+ case "turn:start": {
551
+ const ids = Object.keys(action.agents);
552
+ const history = state.turn > 0 ? [...state.history, snapshot(state)].slice(-MAX_HISTORY) : state.history;
553
+ return {
554
+ phase: "running",
555
+ name: state.name,
556
+ turn: state.turn + 1,
557
+ requirement: action.requirement,
558
+ startedAt: action.startedAt,
559
+ stages: [],
560
+ leaderIds: action.leaderIds,
561
+ agents: action.agents,
562
+ focusId: ids[0],
563
+ autoFollow: true,
564
+ scroll: 0,
565
+ history,
566
+ // A new requirement replaces the previous answer; leaving it up would
567
+ // make the old reply look like the response to the new request. Reading
568
+ // a past turn ends for the same reason — the panes belong to the run.
569
+ reply: void 0,
570
+ view: void 0
571
+ };
572
+ }
573
+ case "focus:cycle":
574
+ return mapShown(state, (turn) => {
575
+ const ids = Object.keys(turn.agents);
576
+ if (ids.length === 0) return turn;
577
+ const current = turn.focusId ? ids.indexOf(turn.focusId) : -1;
578
+ const nextIndex = (current + action.delta + ids.length) % ids.length;
579
+ if (nextIndex === current) return turn;
580
+ return { ...turn, focusId: ids[nextIndex], autoFollow: false, scroll: 0 };
581
+ });
582
+ case "focus:set":
583
+ return mapShown(state, (turn) => {
584
+ if (!turn.agents[action.agentId]) return turn;
585
+ if (turn.focusId === action.agentId && !turn.autoFollow && turn.scroll === 0) return turn;
586
+ return { ...turn, focusId: action.agentId, autoFollow: false, scroll: 0 };
587
+ });
588
+ case "stream:scroll":
589
+ return mapShown(state, (turn) => {
590
+ const lines = focusedLineCount(turn);
591
+ const maxScroll = Math.max(0, lines - Math.max(1, action.viewport));
592
+ const next = Math.min(Math.max(0, turn.scroll + action.delta), maxScroll);
593
+ return next === turn.scroll ? turn : { ...turn, scroll: next };
594
+ });
595
+ case "stream:clear":
596
+ return mapShown(state, (turn) => {
597
+ const id = turn.focusId;
598
+ const agent = id ? turn.agents[id] : void 0;
599
+ if (!id || !agent) return turn;
600
+ return { ...turn, agents: { ...turn.agents, [id]: { ...agent, logs: "" } }, scroll: 0 };
601
+ });
602
+ case "view:open": {
603
+ const record = state.history.find((turn) => turn.turn === action.turn);
604
+ return record ? { ...state, view: record } : state;
605
+ }
606
+ case "view:close":
607
+ return state.view ? { ...state, view: void 0 } : state;
608
+ }
609
+ }
610
+ function stageStatus(stage, agents) {
611
+ const statuses = stage.agents.map((a) => agents[a.id]?.status ?? "pending");
612
+ if (statuses.some((s) => s === "failed")) return "failed";
613
+ if (statuses.some((s) => s === "running" || s === "retrying")) return "running";
614
+ if (statuses.length > 0 && statuses.every((s) => s === "success")) return "success";
615
+ if (statuses.length > 0 && statuses.every((s) => s === "skipped")) return "skipped";
616
+ return "pending";
617
+ }
618
+ var SETTLED = /* @__PURE__ */ new Set(["success", "failed", "skipped"]);
619
+ function progress(turn) {
620
+ const agents = Object.values(turn.agents);
621
+ return { done: agents.filter((a) => SETTLED.has(a.status)).length, total: agents.length };
622
+ }
623
+ function agentElapsed(agent, now) {
624
+ if (agent.status === "running" || agent.status === "retrying") {
625
+ return agent.startedAt === void 0 ? void 0 : Math.max(0, now - agent.startedAt);
626
+ }
627
+ return agent.duration;
628
+ }
629
+ function turnElapsed(turn, now) {
630
+ if (turn.totalDuration !== void 0) return turn.totalDuration;
631
+ if (turn.startedAt === void 0) return void 0;
632
+ const end = turn.phase === "running" ? now : turn.endedAt ?? now;
633
+ return Math.max(0, end - turn.startedAt);
634
+ }
635
+ function visibleLog(turn, viewport) {
636
+ const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
637
+ const all = agent?.logs ? agent.logs.split("\n") : [];
638
+ const height = Math.max(1, viewport);
639
+ const maxScroll = Math.max(0, all.length - height);
640
+ const scrolledBack = Math.min(Math.max(0, turn.scroll), maxScroll);
641
+ const end = all.length - scrolledBack;
642
+ return {
643
+ lines: all.slice(Math.max(0, end - height), end),
644
+ scrolledBack,
645
+ following: scrolledBack === 0
646
+ };
647
+ }
648
+
649
+ // src/tui/components/overlays.tsx
650
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
651
+ var HELP_GROUPS = [
652
+ {
653
+ title: "prompt",
654
+ rows: [
655
+ ["enter", "Run the requirement"],
656
+ ["enter (busy)", "Queue it to run next"],
657
+ ["esc", "Clear the line, close an overlay"],
658
+ ["\u2190 \u2192 home end", "Move the caret"]
659
+ ]
660
+ },
661
+ {
662
+ title: "panes",
663
+ rows: [
664
+ ["tab \u21E7tab", "Next / previous agent"],
665
+ ["\u2191 \u2193", "Scroll the stream one line"],
666
+ ["pgup pgdn", "Scroll the stream a page"],
667
+ ["ctrl+l", "Clear the focused log"],
668
+ ["esc", "Stop reading a past turn"]
669
+ ]
670
+ },
671
+ {
672
+ title: "commands",
673
+ rows: [
674
+ ["/", "Command menu"],
675
+ ["/history", "Browse finished turns"],
676
+ ["?", "This help"],
677
+ ["ctrl+c", "Cancel the turn and its queue \xB7 again to quit"]
678
+ ]
679
+ }
680
+ ];
681
+ var KEY_COLUMN = 16;
682
+ var TURN_ROWS = 3;
683
+ var HISTORY_CHROME_ROWS = 4;
684
+ function historyCapacity(maxRows) {
685
+ return Math.max(1, Math.floor((maxRows - HISTORY_CHROME_ROWS) / TURN_ROWS));
686
+ }
687
+ function historyWindowStart(selected, total, capacity) {
688
+ const maxStart = Math.max(0, total - capacity);
689
+ return Math.min(Math.max(0, Math.min(selected, selected - capacity + 1)), maxStart);
690
+ }
691
+ function helpRows(compact = false) {
692
+ const gap = compact ? 0 : 1;
693
+ const groups = HELP_GROUPS.reduce((total, group) => total + gap + 1 + group.rows.length, 0);
694
+ return 1 + gap + groups + gap + 1;
695
+ }
696
+ function HelpOverlay({ width, maxRows }) {
697
+ const compact = helpRows(false) > maxRows;
698
+ const gap = compact ? 0 : 1;
699
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
700
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "? keys" }),
701
+ HELP_GROUPS.map((group) => /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: gap, children: [
702
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: group.title }),
703
+ group.rows.map(([keys, description]) => /* @__PURE__ */ jsxs2(Box2, { children: [
704
+ /* @__PURE__ */ jsx2(Text2, { color: COLOR.accent, children: padRight(keys, KEY_COLUMN) }),
705
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, wrap: "truncate-end", children: description })
706
+ ] }, keys))
707
+ ] }, group.title)),
708
+ /* @__PURE__ */ jsx2(Box2, { marginTop: gap, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "esc or ? to close" }) })
709
+ ] });
710
+ }
711
+ function outcome(turn) {
712
+ if (turn.reply) {
713
+ return turn.reply.mode === "ask" ? { text: `${GLYPH.ask} needs input`, color: COLOR.warn } : { text: `${STATUS.success.glyph} answered`, color: COLOR.ok };
714
+ }
715
+ if (turn.phase !== "complete") return { text: "interrupted", color: COLOR.warn };
716
+ return turn.success ? { text: `${STATUS.success.glyph} done`, color: COLOR.ok } : { text: `${STATUS.failed.glyph} failed`, color: COLOR.error };
717
+ }
718
+ function HistoryOverlay({
719
+ turns,
720
+ selected,
721
+ width,
722
+ maxRows
723
+ }) {
724
+ const capacity = historyCapacity(maxRows);
725
+ const start = historyWindowStart(selected, turns.length, capacity);
726
+ const visible = turns.slice(start, start + capacity);
727
+ const range = turns.length > capacity ? ` \xB7 showing ${start + 1}\u2013${start + visible.length} of ${turns.length}` : "";
728
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
729
+ /* @__PURE__ */ jsxs2(Box2, { justifyContent: "space-between", children: [
730
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "HISTORY" }),
731
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
732
+ turns.length,
733
+ " earlier"
734
+ ] })
735
+ ] }),
736
+ turns.length === 0 ? /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "No earlier turns yet." }) }) : /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, children: visible.map((turn, offset) => {
737
+ const index = start + offset;
738
+ const focused = index === selected;
739
+ const state = outcome(turn);
740
+ const { done, total } = progress(turn);
741
+ const time = turn.totalDuration === void 0 ? "" : ` \xB7 ${formatDuration(turn.totalDuration)}`;
742
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [
743
+ /* @__PURE__ */ jsxs2(Box2, { children: [
744
+ /* @__PURE__ */ jsx2(Text2, { color: focused ? COLOR.accent : void 0, children: focused ? `${GLYPH.caret} ` : " " }),
745
+ /* @__PURE__ */ jsxs2(Text2, { bold: focused, color: focused ? COLOR.accent : void 0, children: [
746
+ "turn ",
747
+ turn.turn
748
+ ] }),
749
+ /* @__PURE__ */ jsxs2(Text2, { color: state.color, children: [
750
+ " \xB7 ",
751
+ state.text
752
+ ] }),
753
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
754
+ time,
755
+ " \xB7 ",
756
+ done,
757
+ "/",
758
+ total,
759
+ " agents"
760
+ ] })
761
+ ] }),
762
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, wrap: "truncate-end", children: [
763
+ " ",
764
+ truncate(turn.requirement ?? "(no requirement)", Math.max(8, width - 6))
765
+ ] })
766
+ ] }, turn.turn);
767
+ }) }),
768
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: `\u2191\u2193 select \xB7 enter open \xB7 esc close${range}` }) })
769
+ ] });
770
+ }
771
+
772
+ // src/tui/components/panels.tsx
773
+ import { Box as Box3, Text as Text3 } from "ink";
774
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
775
+ function rightColumn(agent, now) {
776
+ if (agent.status === "retrying" && agent.retry) {
777
+ return { text: `retry ${agent.retry.attempt}/${agent.retry.max}`, color: COLOR.warn };
778
+ }
779
+ const elapsed = agentElapsed(agent, now);
780
+ if (elapsed !== void 0) return { text: formatDuration(elapsed) };
781
+ if (agent.status === "skipped") return { text: "skipped" };
782
+ return { text: "\u2014" };
783
+ }
784
+ function TeamRow({
785
+ agent,
786
+ focused,
787
+ now,
788
+ spinner
789
+ }) {
790
+ const style = STATUS[agent.status];
791
+ const glyph = agent.status === "running" ? spinner : style.glyph;
792
+ const right = rightColumn(agent, now);
793
+ return /* @__PURE__ */ jsxs3(Box3, { justifyContent: "space-between", children: [
794
+ /* @__PURE__ */ jsx3(Box3, { flexShrink: 1, children: /* @__PURE__ */ jsxs3(
795
+ Text3,
796
+ {
797
+ color: focused ? COLOR.accent : style.color,
798
+ dimColor: !focused && style.dim,
799
+ bold: focused,
800
+ wrap: "truncate-end",
801
+ children: [
802
+ focused ? `${GLYPH.caret} ` : " ",
803
+ glyph,
804
+ " ",
805
+ agent.icon ? `${agent.icon} ` : "",
806
+ agent.name
807
+ ]
808
+ }
809
+ ) }),
810
+ /* @__PURE__ */ jsx3(Box3, { flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { color: right.color, dimColor: right.color === void 0, children: right.text }) })
811
+ ] });
812
+ }
813
+ function TeamPanel({
814
+ turn,
815
+ width,
816
+ now,
817
+ spinner
818
+ }) {
819
+ const ids = Object.keys(turn.agents);
820
+ const { done, total } = progress(turn);
821
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width, flexShrink: 0, paddingRight: 1, children: [
822
+ /* @__PURE__ */ jsxs3(Box3, { justifyContent: "space-between", children: [
823
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "TEAM" }),
824
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
825
+ done,
826
+ "/",
827
+ total
828
+ ] })
829
+ ] }),
830
+ ids.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "no agents configured" }) : ids.map((id) => /* @__PURE__ */ jsx3(
831
+ TeamRow,
832
+ {
833
+ agent: turn.agents[id],
834
+ focused: turn.focusId === id,
835
+ now,
836
+ spinner
837
+ },
838
+ id
839
+ ))
840
+ ] });
841
+ }
842
+ function Divider() {
843
+ return /* @__PURE__ */ jsx3(
844
+ Box3,
845
+ {
846
+ flexShrink: 0,
847
+ borderStyle: "single",
848
+ borderTop: false,
849
+ borderBottom: false,
850
+ borderRight: false,
851
+ borderLeft: true,
852
+ borderColor: COLOR.muted
853
+ }
854
+ );
855
+ }
856
+ function StreamPanel({
857
+ turn,
858
+ height,
859
+ now,
860
+ spinner
861
+ }) {
862
+ const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
863
+ const viewport = Math.max(1, height - 1);
864
+ const window = visibleLog(turn, viewport);
865
+ const style = agent ? STATUS[agent.status] : void 0;
866
+ const elapsed = agent ? agentElapsed(agent, now) : void 0;
867
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexGrow: 1, height, paddingLeft: 1, children: [
868
+ /* @__PURE__ */ jsxs3(Box3, { justifyContent: "space-between", children: [
869
+ /* @__PURE__ */ jsxs3(Box3, { flexShrink: 1, children: [
870
+ /* @__PURE__ */ jsx3(Text3, { bold: true, wrap: "truncate-end", children: agent?.name ?? "\u2014" }),
871
+ agent?.model ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
872
+ " \xB7 ",
873
+ agent.model
874
+ ] }) : null,
875
+ agent && style ? /* @__PURE__ */ jsxs3(Fragment, { children: [
876
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " \xB7 " }),
877
+ /* @__PURE__ */ jsx3(Text3, { color: style.color, dimColor: style.dim, children: agent.status === "running" ? `${spinner} ${style.label}` : style.label })
878
+ ] }) : null
879
+ ] }),
880
+ /* @__PURE__ */ jsxs3(Box3, { flexShrink: 0, children: [
881
+ elapsed === void 0 ? null : /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
882
+ formatDuration(elapsed),
883
+ " "
884
+ ] }),
885
+ /* @__PURE__ */ jsx3(Text3, { color: window.following ? COLOR.accent : COLOR.warn, children: window.following ? GLYPH.live : `${GLYPH.paused} ${window.scrolledBack}\u2191` })
886
+ ] })
887
+ ] }),
888
+ window.lines.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: turn.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : window.lines.map((line, index) => (
889
+ // Log lines are an append-only tail; position is the identity.
890
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional log tail
891
+ /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: line }, index)
892
+ ))
893
+ ] });
894
+ }
895
+ function ReplyPanel({ reply, width }) {
896
+ const asking = reply.mode === "ask";
897
+ return (
898
+ // `flexShrink={0}`: the body must clip this, not compress it. A shrunken
899
+ // column box folds its own text rows into each other before the body ever
900
+ // gets a chance to hide the overflow.
901
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
902
+ /* @__PURE__ */ jsxs3(Text3, { color: asking ? COLOR.warn : COLOR.accent, bold: true, children: [
903
+ asking ? GLYPH.ask : GLYPH.leader,
904
+ " ",
905
+ asking ? "the team needs a decision" : "answered"
906
+ ] }),
907
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { children: reply.message }) }),
908
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: asking ? "Answer below and press enter." : "Type a requirement and press enter." }) })
909
+ ] })
910
+ );
911
+ }
912
+ function WelcomePanel({ turn, width }) {
913
+ const agents = Object.values(turn.agents);
914
+ const leaderId = turn.leaderIds[0];
915
+ const nameWidth = Math.min(24, Math.max(8, ...agents.map((a) => a.name.length)) + 2);
916
+ return (
917
+ // `flexShrink={0}` — see ReplyPanel. Compressing the roster folds one agent's
918
+ // description onto the next row's, which reads as a corrupted screen.
919
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
920
+ /* @__PURE__ */ jsxs3(Text3, { color: COLOR.accent, bold: true, children: [
921
+ GLYPH.brand,
922
+ " multiclaw"
923
+ ] }),
924
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "A team of agents, one prompt away." }) }),
925
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginTop: 1, children: agents.map((agent) => {
926
+ const focused = turn.focusId === agent.id;
927
+ const leader = agent.id === leaderId;
928
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
929
+ /* @__PURE__ */ jsxs3(Box3, { flexShrink: 0, children: [
930
+ /* @__PURE__ */ jsx3(Text3, { color: focused ? COLOR.accent : void 0, children: focused ? `${GLYPH.caret} ` : " " }),
931
+ /* @__PURE__ */ jsx3(Text3, { color: leader ? COLOR.accent : COLOR.muted, children: leader ? GLYPH.leader : GLYPH.agent }),
932
+ /* @__PURE__ */ jsxs3(Text3, { bold: focused, color: focused ? COLOR.accent : void 0, children: [
933
+ " ",
934
+ padRight(truncate(agent.name, nameWidth - 2), nameWidth - 1)
935
+ ] })
936
+ ] }),
937
+ /* @__PURE__ */ jsx3(Box3, { flexShrink: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: agent.taskTitle ?? agent.description ?? "" }) })
938
+ ] }, agent.id);
939
+ }) }),
940
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Type a requirement and press enter." }) })
941
+ ] })
942
+ );
943
+ }
944
+
945
+ // src/tui/components/prompt.tsx
946
+ import { Box as Box4, Text as Text4 } from "ink";
947
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
948
+ function Prompt({
949
+ value,
950
+ cursor,
951
+ caretVisible,
952
+ placeholder
953
+ }) {
954
+ const ghost = value === "";
955
+ const before = value.slice(0, cursor);
956
+ const cell = ghost ? placeholder.slice(0, 1) : value.slice(cursor, cursor + 1);
957
+ const tail = ghost ? placeholder.slice(1) : value.slice(cursor + 1);
958
+ return /* @__PURE__ */ jsxs4(Box4, { flexShrink: 0, children: [
959
+ /* @__PURE__ */ jsxs4(Text4, { color: COLOR.accent, children: [
960
+ GLYPH.caret,
961
+ " "
962
+ ] }),
963
+ /* @__PURE__ */ jsx4(Text4, { dimColor: ghost, children: before }),
964
+ /* @__PURE__ */ jsx4(Text4, { inverse: caretVisible, dimColor: !caretVisible && ghost, children: cell || " " }),
965
+ /* @__PURE__ */ jsx4(Text4, { dimColor: ghost, children: tail })
966
+ ] });
967
+ }
968
+ function CommandMenu({ commands, selected }) {
969
+ const nameWidth = Math.max(
970
+ 1,
971
+ ...commands.map((c) => c.name.length + (c.args ? c.args.length + 1 : 0))
972
+ );
973
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", paddingLeft: 2, children: commands.map((command, index) => {
974
+ const active = index === selected;
975
+ const label = command.args ? `${command.name} ${command.args}` : command.name;
976
+ return /* @__PURE__ */ jsxs4(
977
+ Text4,
978
+ {
979
+ color: active ? COLOR.accent : void 0,
980
+ dimColor: !active,
981
+ wrap: "truncate-end",
982
+ children: [
983
+ active ? GLYPH.caret : " ",
984
+ " /",
985
+ padRight(label, nameWidth),
986
+ " ",
987
+ command.description
988
+ ]
989
+ },
990
+ command.name
991
+ );
992
+ }) });
993
+ }
994
+
995
+ // src/tui/App.tsx
996
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
997
+ var FLUSH_INTERVAL_MS = 80;
998
+ var SPINNER_INTERVAL_MS = 110;
999
+ var BLINK_INTERVAL_MS = 530;
1000
+ var INPUT_PLACEHOLDER = "describe what the team should do\u2026";
1001
+ var RUNNING_PLACEHOLDER = "type to queue a follow-up\u2026";
1002
+ var EMPTY_LINE = { text: "", cursor: 0 };
1003
+ function StatusLine({
1004
+ state,
1005
+ running,
1006
+ cancelled,
1007
+ error,
1008
+ now,
1009
+ spinner,
1010
+ queued
1011
+ }) {
1012
+ const { done, total } = progress(state);
1013
+ if (error)
1014
+ return /* @__PURE__ */ jsx5(Text5, { color: COLOR.error, wrap: "truncate-end", children: error });
1015
+ if (state.warning && state.phase !== "complete")
1016
+ return /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, wrap: "truncate-end", children: state.warning });
1017
+ if (cancelled && !running) return /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, children: "cancelled" });
1018
+ if (state.reply) {
1019
+ const asking = state.reply.mode === "ask";
1020
+ return /* @__PURE__ */ jsx5(Text5, { color: asking ? COLOR.warn : COLOR.ok, wrap: "truncate-end", children: asking ? `${GLYPH.ask} needs your input` : `${STATUS.success.glyph} answered` });
1021
+ }
1022
+ if (running) {
1023
+ const elapsed = turnElapsed(state, now);
1024
+ const time = elapsed === void 0 ? "" : ` ${formatDuration(elapsed)}`;
1025
+ return /* @__PURE__ */ jsxs5(Text5, { color: COLOR.accent, wrap: "truncate-end", children: [
1026
+ spinner,
1027
+ " running",
1028
+ time,
1029
+ " \xB7 ",
1030
+ done,
1031
+ "/",
1032
+ total,
1033
+ " agents",
1034
+ queued > 0 ? ` \xB7 ${queued} queued` : ""
1035
+ ] });
1036
+ }
1037
+ if (state.phase === "complete") {
1038
+ const time = state.totalDuration === void 0 ? "" : ` ${formatDuration(state.totalDuration)}`;
1039
+ const outcome2 = state.success ? `${STATUS.success.glyph} done${time}` : `${STATUS.failed.glyph} finished with failures${time}`;
1040
+ return /* @__PURE__ */ jsxs5(Text5, { color: state.success ? COLOR.ok : COLOR.error, wrap: "truncate-end", children: [
1041
+ outcome2,
1042
+ state.warning ? ` \xB7 ${GLYPH.warn} ${state.warning}` : ""
1043
+ ] });
1044
+ }
1045
+ const stages = state.stages.length;
1046
+ return /* @__PURE__ */ jsxs5(Text5, { dimColor: true, wrap: "truncate-end", children: [
1047
+ "ready \xB7 ",
1048
+ total,
1049
+ " agents",
1050
+ stages > 0 ? ` \xB7 ${stages} stages` : ""
1051
+ ] });
1052
+ }
1053
+ function App({ config, onTurnStart }) {
1054
+ const { exit } = useApp();
1055
+ const { columns, rows } = useWindowSize();
1056
+ const [state, dispatch] = useReducer(tuiReducer, config, createInitialState);
1057
+ const stateRef = useRef(state);
1058
+ stateRef.current = state;
1059
+ const [line, setLine] = useState(EMPTY_LINE);
1060
+ const [caretOn, setCaretOn] = useState(true);
1061
+ const [tick, setTick] = useState(0);
1062
+ const [running, setRunning] = useState(false);
1063
+ const [queue, setQueue] = useState([]);
1064
+ const [cancelled, setCancelled] = useState(false);
1065
+ const [error, setError] = useState();
1066
+ const [notice, setNotice] = useState();
1067
+ const [helpOpen, setHelpOpen] = useState(false);
1068
+ const [historyOpen, setHistoryOpen] = useState(false);
1069
+ const [historyCursor, setHistoryCursor] = useState(0);
1070
+ const [menuCursor, setMenuCursor] = useState({
1071
+ query: void 0,
1072
+ index: 0
1073
+ });
1074
+ const abortRef = useRef(null);
1075
+ const pending = useRef([]);
1076
+ const flush = useCallback(() => {
1077
+ const batch = pending.current;
1078
+ if (batch.length === 0) return;
1079
+ pending.current = [];
1080
+ for (const event of batch) dispatch({ type: "event", event });
1081
+ }, []);
1082
+ useEffect(() => {
1083
+ if (!running) return;
1084
+ const timer = setInterval(() => setTick((value) => value + 1), SPINNER_INTERVAL_MS);
1085
+ return () => clearInterval(timer);
1086
+ }, [running]);
1087
+ useEffect(() => {
1088
+ if (!running && line.text === "") {
1089
+ setCaretOn(true);
1090
+ return;
1091
+ }
1092
+ const timer = setInterval(() => setCaretOn((on) => !on), BLINK_INTERVAL_MS);
1093
+ return () => clearInterval(timer);
1094
+ }, [running, line.text]);
1095
+ const startTurn = useCallback(
1096
+ async (requirement) => {
1097
+ setError(void 0);
1098
+ setCancelled(false);
1099
+ setNotice(void 0);
1100
+ const earlierTurns = conversationTurns(stateRef.current);
1101
+ dispatch({
1102
+ type: "turn:start",
1103
+ requirement,
1104
+ agents: seedAgents(config),
1105
+ leaderIds: leaderAgentIds(config),
1106
+ startedAt: Date.now()
1107
+ });
1108
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1109
+ const logDir = path.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
1110
+ const runConfig = {
1111
+ ...config,
1112
+ context: { ...config.context, requirement },
1113
+ // An answer only reads as a requirement next to the question it answers,
1114
+ // so the leader is handed the turns behind this one as well.
1115
+ history: earlierTurns,
1116
+ logDir
1117
+ };
1118
+ const controller = new AbortController();
1119
+ abortRef.current = controller;
1120
+ setRunning(true);
1121
+ const orchestrator = new Orchestrator(runConfig);
1122
+ const unsubscribe = orchestrator.eventBus.subscribe((event) => {
1123
+ pending.current.push(event);
1124
+ });
1125
+ onTurnStart?.(orchestrator.eventBus, logDir);
1126
+ const timer = setInterval(flush, FLUSH_INTERVAL_MS);
1127
+ try {
1128
+ await orchestrator.run({ signal: controller.signal });
1129
+ } catch (err) {
1130
+ if (isAbortError(err)) setCancelled(true);
1131
+ else setError(err.message);
1132
+ } finally {
1133
+ clearInterval(timer);
1134
+ flush();
1135
+ unsubscribe();
1136
+ abortRef.current = null;
1137
+ setRunning(false);
1138
+ }
1139
+ },
1140
+ [config, flush, onTurnStart]
1141
+ );
1142
+ useEffect(() => {
1143
+ if (running || queue.length === 0) return;
1144
+ const [next, ...rest] = queue;
1145
+ setQueue(rest);
1146
+ void startTurn(next);
1147
+ }, [running, queue, startTurn]);
1148
+ const runCommand = useCallback(
1149
+ (name, args) => {
1150
+ setLine(EMPTY_LINE);
1151
+ const shown2 = state.view ?? state;
1152
+ switch (name) {
1153
+ case "agents":
1154
+ setNotice(
1155
+ `team: ${plannedAgents(config).map((agent) => agent.id).join(", ")}`
1156
+ );
1157
+ return;
1158
+ case "focus": {
1159
+ const id = args[0];
1160
+ if (!id) {
1161
+ setNotice("usage: /focus <agent id>");
1162
+ return;
1163
+ }
1164
+ if (!shown2.agents[id]) {
1165
+ setNotice(`unknown agent: ${id}`);
1166
+ return;
1167
+ }
1168
+ dispatch({ type: "focus:set", agentId: id });
1169
+ setNotice(`focused ${id}`);
1170
+ return;
1171
+ }
1172
+ case "clear":
1173
+ if (!shown2.focusId) {
1174
+ setNotice("nothing focused");
1175
+ return;
1176
+ }
1177
+ dispatch({ type: "stream:clear" });
1178
+ setNotice(`cleared ${shown2.focusId}`);
1179
+ return;
1180
+ case "history":
1181
+ if (state.history.length === 0) {
1182
+ setNotice("no earlier turns yet");
1183
+ return;
1184
+ }
1185
+ setNotice(void 0);
1186
+ setHistoryCursor(0);
1187
+ setHistoryOpen(true);
1188
+ return;
1189
+ case "cancel": {
1190
+ if (!abortRef.current) {
1191
+ setNotice("nothing to cancel");
1192
+ return;
1193
+ }
1194
+ const dropped = queue.length;
1195
+ abortRef.current.abort();
1196
+ setQueue([]);
1197
+ setCancelled(true);
1198
+ setNotice(dropped > 0 ? `cancelling\u2026 \xB7 dropped ${dropped} queued` : "cancelling\u2026");
1199
+ return;
1200
+ }
1201
+ case "help":
1202
+ setNotice(void 0);
1203
+ setHelpOpen(true);
1204
+ return;
1205
+ case "quit":
1206
+ exit();
1207
+ return;
1208
+ default:
1209
+ setNotice(`unknown command: /${name}`);
1210
+ }
1211
+ },
1212
+ [config, exit, queue.length, state]
1213
+ );
1214
+ const submit = useCallback(() => {
1215
+ const text = line.text.trim();
1216
+ if (!text) return;
1217
+ const command = parseCommand(text);
1218
+ if (command) {
1219
+ runCommand(command.name, command.args);
1220
+ return;
1221
+ }
1222
+ setLine(EMPTY_LINE);
1223
+ setNotice(void 0);
1224
+ if (running) {
1225
+ setQueue((pending2) => [...pending2, text]);
1226
+ return;
1227
+ }
1228
+ void startTurn(text);
1229
+ }, [line.text, runCommand, running, startTurn]);
1230
+ const insert = useCallback((chunk) => {
1231
+ setLine((l) => ({
1232
+ text: l.text.slice(0, l.cursor) + chunk + l.text.slice(l.cursor),
1233
+ cursor: l.cursor + chunk.length
1234
+ }));
1235
+ }, []);
1236
+ const backspace = useCallback(() => {
1237
+ setLine(
1238
+ (l) => l.cursor === 0 ? l : { text: l.text.slice(0, l.cursor - 1) + l.text.slice(l.cursor), cursor: l.cursor - 1 }
1239
+ );
1240
+ }, []);
1241
+ const deleteForward = useCallback(() => {
1242
+ setLine(
1243
+ (l) => l.cursor >= l.text.length ? l : { text: l.text.slice(0, l.cursor) + l.text.slice(l.cursor + 1), cursor: l.cursor }
1244
+ );
1245
+ }, []);
1246
+ const move = useCallback((delta) => {
1247
+ setLine((l) => ({ ...l, cursor: Math.min(Math.max(0, l.cursor + delta), l.text.length) }));
1248
+ }, []);
1249
+ const moveTo = useCallback((cursor) => {
1250
+ setLine((l) => ({ ...l, cursor }));
1251
+ }, []);
1252
+ const query = menuQuery(line.text);
1253
+ const matches = useMemo(
1254
+ () => query === void 0 ? [] : matchCommands(query, running),
1255
+ [query, running]
1256
+ );
1257
+ const menuIndex = menuCursor.query === query ? menuCursor.index : 0;
1258
+ const selected = matches.length === 0 ? 0 : Math.min(menuIndex, matches.length - 1);
1259
+ const menuOpen = !helpOpen && !historyOpen && query !== void 0 && matches.length > 0;
1260
+ const historyTurns = useMemo(() => [...state.history].reverse(), [state.history]);
1261
+ const historyIndex = historyTurns.length === 0 ? 0 : Math.min(historyCursor, historyTurns.length - 1);
1262
+ const shown = state.view ?? state;
1263
+ const layout = computeLayout({
1264
+ columns,
1265
+ rows,
1266
+ turn: state.turn,
1267
+ overlay: helpOpen || historyOpen,
1268
+ menuRows: menuOpen ? matches.length : 0,
1269
+ queueRows: queue.length,
1270
+ notice: notice !== void 0,
1271
+ viewing: state.view !== void 0,
1272
+ hasRail: shown.leaderIds.length > 0 || shown.stages.length > 0,
1273
+ railCells: railCellWidths(shown)
1274
+ });
1275
+ const menuWindow = matches.slice(0, layout.menuRows);
1276
+ const menuSelected = Math.min(selected, Math.max(0, menuWindow.length - 1));
1277
+ const now = Date.now();
1278
+ const spinner = SPINNER[tick % SPINNER.length];
1279
+ const handleKey = (char, key) => {
1280
+ if (key.ctrl && char === "c") {
1281
+ if (helpOpen) {
1282
+ setHelpOpen(false);
1283
+ } else if (historyOpen) {
1284
+ setHistoryOpen(false);
1285
+ } else if (abortRef.current) {
1286
+ abortRef.current.abort();
1287
+ setNotice(queue.length > 0 ? `cancelling\u2026 \xB7 dropped ${queue.length} queued` : "cancelling\u2026");
1288
+ setQueue([]);
1289
+ setCancelled(true);
1290
+ } else if (line.text) {
1291
+ setLine(EMPTY_LINE);
1292
+ } else {
1293
+ exit();
1294
+ }
1295
+ return;
1296
+ }
1297
+ if (helpOpen) {
1298
+ if (key.escape || char === "?") setHelpOpen(false);
1299
+ return;
1300
+ }
1301
+ if (historyOpen) {
1302
+ if (key.escape) {
1303
+ setHistoryOpen(false);
1304
+ return;
1305
+ }
1306
+ if (historyTurns.length === 0) return;
1307
+ if (key.upArrow) {
1308
+ setHistoryCursor((historyIndex - 1 + historyTurns.length) % historyTurns.length);
1309
+ return;
1310
+ }
1311
+ if (key.downArrow) {
1312
+ setHistoryCursor((historyIndex + 1) % historyTurns.length);
1313
+ return;
1314
+ }
1315
+ if (key.return) {
1316
+ dispatch({ type: "view:open", turn: historyTurns[historyIndex].turn });
1317
+ setHistoryOpen(false);
1318
+ return;
1319
+ }
1320
+ return;
1321
+ }
1322
+ if (key.ctrl && char === "d") {
1323
+ if (!line.text) exit();
1324
+ return;
1325
+ }
1326
+ if (key.ctrl && char === "l") {
1327
+ dispatch({ type: "stream:clear" });
1328
+ setNotice(shown.focusId ? `cleared ${shown.focusId}` : "nothing focused");
1329
+ return;
1330
+ }
1331
+ if (key.ctrl && char === "a") {
1332
+ moveTo(0);
1333
+ return;
1334
+ }
1335
+ if (key.ctrl && char === "e") {
1336
+ setLine((l) => ({ ...l, cursor: l.text.length }));
1337
+ return;
1338
+ }
1339
+ if (key.tab) {
1340
+ dispatch({ type: "focus:cycle", delta: key.shift ? -1 : 1 });
1341
+ return;
1342
+ }
1343
+ if (menuOpen) {
1344
+ if (key.upArrow) {
1345
+ setMenuCursor({ query, index: (menuIndex - 1 + matches.length) % matches.length });
1346
+ return;
1347
+ }
1348
+ if (key.downArrow) {
1349
+ setMenuCursor({ query, index: (menuIndex + 1) % matches.length });
1350
+ return;
1351
+ }
1352
+ if (key.escape) {
1353
+ setLine(EMPTY_LINE);
1354
+ return;
1355
+ }
1356
+ if (key.return) {
1357
+ runCommand(matches[selected].name, []);
1358
+ return;
1359
+ }
1360
+ }
1361
+ if (key.upArrow) {
1362
+ dispatch({ type: "stream:scroll", delta: 1, viewport: layout.logViewport });
1363
+ return;
1364
+ }
1365
+ if (key.downArrow) {
1366
+ dispatch({ type: "stream:scroll", delta: -1, viewport: layout.logViewport });
1367
+ return;
1368
+ }
1369
+ if (key.pageUp) {
1370
+ dispatch({ type: "stream:scroll", delta: layout.logViewport, viewport: layout.logViewport });
1371
+ return;
1372
+ }
1373
+ if (key.pageDown) {
1374
+ dispatch({ type: "stream:scroll", delta: -layout.logViewport, viewport: layout.logViewport });
1375
+ return;
1376
+ }
1377
+ if (char === "?" && line.text === "" && !running) {
1378
+ setHelpOpen(true);
1379
+ return;
1380
+ }
1381
+ if (key.return) {
1382
+ submit();
1383
+ return;
1384
+ }
1385
+ if (key.leftArrow) {
1386
+ move(-1);
1387
+ return;
1388
+ }
1389
+ if (key.rightArrow) {
1390
+ move(1);
1391
+ return;
1392
+ }
1393
+ if (key.home) {
1394
+ moveTo(0);
1395
+ return;
1396
+ }
1397
+ if (key.end) {
1398
+ setLine((l) => ({ ...l, cursor: l.text.length }));
1399
+ return;
1400
+ }
1401
+ if (key.escape) {
1402
+ if (state.view) {
1403
+ dispatch({ type: "view:close" });
1404
+ return;
1405
+ }
1406
+ setLine(EMPTY_LINE);
1407
+ setNotice(void 0);
1408
+ return;
1409
+ }
1410
+ if (key.backspace) {
1411
+ backspace();
1412
+ return;
1413
+ }
1414
+ if (key.delete) {
1415
+ deleteForward();
1416
+ return;
1417
+ }
1418
+ if (char && !key.ctrl && !key.meta) insert(char.replace(/[\r\n]+/g, " "));
1419
+ };
1420
+ const handlerRef = useRef(handleKey);
1421
+ handlerRef.current = handleKey;
1422
+ useInput((char, key) => handlerRef.current(char, key));
1423
+ const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : menuOpen ? "\u2191\u2193 select \xB7 enter run \xB7 esc close" : running ? "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
1424
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", height: layout.rows, children: [
1425
+ /* @__PURE__ */ jsx5(
1426
+ Header,
1427
+ {
1428
+ name: state.name || config.name,
1429
+ right: state.view ? `turn ${shown.turn} \xB7 viewing` : `turn ${shown.turn} \xB7 ${running ? formatDuration(turnElapsed(state, now) ?? 0) : state.phase === "complete" ? "complete" : "ready"}`
1430
+ }
1431
+ ),
1432
+ /* @__PURE__ */ jsx5(Rule, { width: layout.columns }),
1433
+ config.useLeader === false ? /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, dimColor: true, wrap: "truncate-end", children: " \u26A0 leader off \u2014 every message runs the full team" }) : null,
1434
+ layout.showRail ? (
1435
+ // The rail wraps, so it can be taller than one row. Cap it at the height
1436
+ // the budget reserved and clip, rather than letting the extra rows push
1437
+ // the chrome below it off the screen.
1438
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, height: layout.railRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx5(PipelineRail, { turn: shown, spinner }) })
1439
+ ) : null,
1440
+ /* @__PURE__ */ jsx5(
1441
+ Box5,
1442
+ {
1443
+ marginTop: 1,
1444
+ height: layout.bodyHeight,
1445
+ flexShrink: 0,
1446
+ flexDirection: "column",
1447
+ overflow: "hidden",
1448
+ children: helpOpen ? /* @__PURE__ */ jsx5(HelpOverlay, { width: layout.columns, maxRows: layout.bodyHeight }) : historyOpen ? /* @__PURE__ */ jsx5(
1449
+ HistoryOverlay,
1450
+ {
1451
+ turns: historyTurns,
1452
+ selected: historyIndex,
1453
+ width: layout.columns,
1454
+ maxRows: layout.bodyHeight
1455
+ }
1456
+ ) : layout.welcome ? /* @__PURE__ */ jsx5(WelcomePanel, { turn: state, width: layout.columns }) : shown.reply ? /* @__PURE__ */ jsx5(ReplyPanel, { reply: shown.reply, width: layout.columns }) : /* @__PURE__ */ jsxs5(Box5, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
1457
+ layout.showTeam ? /* @__PURE__ */ jsxs5(Fragment2, { children: [
1458
+ /* @__PURE__ */ jsx5(TeamPanel, { turn: shown, width: layout.teamWidth, now, spinner }),
1459
+ /* @__PURE__ */ jsx5(Divider, {})
1460
+ ] }) : null,
1461
+ /* @__PURE__ */ jsx5(StreamPanel, { turn: shown, height: layout.bodyHeight, now, spinner })
1462
+ ] })
1463
+ }
1464
+ ),
1465
+ state.view ? /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, wrap: "truncate-end", children: ` viewing turn ${shown.turn} \xB7 ${shown.requirement ?? "(no requirement)"} \xB7 esc to return` }) : null,
1466
+ notice ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, wrap: "truncate-end", children: [
1467
+ " ",
1468
+ notice
1469
+ ] }) : null,
1470
+ /* @__PURE__ */ jsx5(QueueStrip, { items: queue, max: layout.queueRows }),
1471
+ menuOpen ? /* @__PURE__ */ jsx5(CommandMenu, { commands: menuWindow, selected: menuSelected }) : null,
1472
+ /* @__PURE__ */ jsx5(
1473
+ Prompt,
1474
+ {
1475
+ value: line.text,
1476
+ cursor: line.cursor,
1477
+ caretVisible: caretOn,
1478
+ placeholder: running ? RUNNING_PLACEHOLDER : INPUT_PLACEHOLDER
1479
+ }
1480
+ ),
1481
+ /* @__PURE__ */ jsx5(
1482
+ HintBar,
1483
+ {
1484
+ status: /* @__PURE__ */ jsx5(
1485
+ StatusLine,
1486
+ {
1487
+ state,
1488
+ running,
1489
+ cancelled,
1490
+ error,
1491
+ now,
1492
+ spinner,
1493
+ queued: queue.length
1494
+ }
1495
+ ),
1496
+ keys
1497
+ }
1498
+ )
1499
+ ] });
1500
+ }
1501
+
1502
+ // src/tui/index.ts
1503
+ async function startTui(options) {
1504
+ if (process.stdout.isTTY !== true) {
1505
+ throw new Error(
1506
+ 'The interactive TUI needs a TTY. Use `multiclaw run "<requirement>"` for headless execution.'
1507
+ );
1508
+ }
1509
+ const restore = () => process.stdout.write("\x1B[?25h\x1B[?1049l");
1510
+ process.stdout.write("\x1B[?1049h\x1B[?25l");
1511
+ process.on("exit", restore);
1512
+ try {
1513
+ const app = render(createElement(App, options), { exitOnCtrlC: false });
1514
+ await app.waitUntilExit();
1515
+ } finally {
1516
+ process.off("exit", restore);
1517
+ restore();
1518
+ }
1519
+ }
1520
+ export {
1521
+ COMPACT_COLUMNS,
1522
+ MAX_HISTORY,
1523
+ MAX_LOG_CHARS,
1524
+ MAX_MENU_ROWS,
1525
+ MAX_QUEUE_ROWS,
1526
+ MAX_RAIL_ROWS,
1527
+ MIN_COLUMNS,
1528
+ MIN_ROWS,
1529
+ RAIL_MIN_ROWS,
1530
+ agentElapsed,
1531
+ appendLog,
1532
+ computeLayout,
1533
+ conversationTurns,
1534
+ createInitialState,
1535
+ initialState,
1536
+ leaderAgentIds,
1537
+ measureRailRows,
1538
+ plannedAgents,
1539
+ progress,
1540
+ reduceEvent,
1541
+ seedAgents,
1542
+ stageStatus,
1543
+ startTui,
1544
+ stripAnsi,
1545
+ tuiReducer,
1546
+ turnElapsed,
1547
+ visibleLog
1548
+ };