@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/README.md +66 -2
- package/dist/tui.d.ts +299 -0
- package/dist/tui.js +1548 -0
- package/package.json +18 -27
- package/components.json +0 -16
- package/index.html +0 -12
- package/postcss.config.js +0 -3
- package/src/client/App.tsx +0 -96
- package/src/client/components/AgentCard.tsx +0 -60
- package/src/client/components/LogPanel.tsx +0 -43
- package/src/client/components/PipelineBar.tsx +0 -234
- package/src/client/components/ui/badge.tsx +0 -28
- package/src/client/components/ui/button.tsx +0 -43
- package/src/client/components/ui/card.tsx +0 -22
- package/src/client/components/ui/scroll-area.tsx +0 -43
- package/src/client/components/ui/separator.tsx +0 -23
- package/src/client/hooks/useWebSocket.ts +0 -33
- package/src/client/index.css +0 -31
- package/src/client/lib/utils.ts +0 -6
- package/src/client/main.tsx +0 -8
- package/src/client/store/useOrchestrationStore.ts +0 -132
- package/src/server/DashboardServer.ts +0 -56
- package/src/server/WebSocketHub.ts +0 -35
- package/src/server/index.ts +0 -2
- package/tailwind.config.js +0 -50
- package/tsconfig.json +0 -12
- package/tsconfig.server.json +0 -9
- package/vite.config.ts +0 -25
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
|
+
};
|