multiarena 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/cli/args.d.ts +11 -0
- package/dist/cli/args.js +56 -0
- package/dist/config/loader.js +2 -2
- package/dist/config/types.d.ts +11 -1
- package/dist/core/deliberation.d.ts +53 -0
- package/dist/core/deliberation.js +356 -0
- package/dist/core/session.d.ts +3 -1
- package/dist/core/session.js +20 -17
- package/dist/core/turn.d.ts +2 -0
- package/dist/core/turn.js +32 -5
- package/dist/index.js +3 -49
- package/dist/isolation/worktree.d.ts +1 -1
- package/dist/isolation/worktree.js +8 -8
- package/dist/persistence/session.js +1 -1
- package/dist/provider/adapters/openai.d.ts +15 -0
- package/dist/provider/adapters/openai.js +67 -8
- package/dist/provider/provider.js +4 -0
- package/dist/tools/builtin/bash.js +6 -1
- package/dist/ui/app.js +426 -46
- package/dist/ui/components/BroadcastSummary.d.ts +1 -0
- package/dist/ui/components/BroadcastSummary.js +24 -8
- package/dist/ui/components/DeliberationView.d.ts +17 -0
- package/dist/ui/components/DeliberationView.js +81 -0
- package/dist/ui/components/InputBar.d.ts +3 -0
- package/dist/ui/components/InputBar.js +18 -8
- package/dist/ui/components/ModelDetail.js +16 -4
- package/dist/ui/components/OutputArea.d.ts +8 -0
- package/dist/ui/components/OutputArea.js +32 -4
- package/dist/ui/components/formatTokens.d.ts +1 -0
- package/dist/ui/components/formatTokens.js +7 -0
- package/dist/ui/modeTransitions.d.ts +80 -0
- package/dist/ui/modeTransitions.js +176 -0
- package/package.json +13 -8
- package/dist/ui/components/StatusBar.d.ts +0 -9
- package/dist/ui/components/StatusBar.js +0 -51
package/dist/ui/app.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { OutputArea } from "./components/OutputArea.js";
|
|
5
5
|
import { InputBar } from "./components/InputBar.js";
|
|
6
|
-
import { Session } from "../core/session.js";
|
|
6
|
+
import { Session, contextLimitForModel } from "../core/session.js";
|
|
7
7
|
import { loadConfig, validateConfig } from "../config/loader.js";
|
|
8
8
|
import { createDefaultRegistry } from "../tools/registry.js";
|
|
9
9
|
import { PermissionManager } from "../tools/permission.js";
|
|
10
10
|
import { runTurn } from "../core/turn.js";
|
|
11
11
|
import { WorktreeManager } from "../isolation/worktree.js";
|
|
12
12
|
import { saveSession, loadSession } from "../persistence/session.js";
|
|
13
|
-
|
|
13
|
+
import { runDeliberation, runMerge, autoAssignRounds, } from "../core/deliberation.js";
|
|
14
|
+
import { reduceTab, reduceShiftTab, reduceEscape, reduceKeyD, reduceSubmitInTeam, buildModeState, } from "./modeTransitions.js";
|
|
15
|
+
function makeSystemPrompt(modelName, provider) {
|
|
16
|
+
return `You are a helpful AI assistant. You can help with coding, writing, analysis, and creative tasks. You are the "${modelName}" model (provider: ${provider}). Respond directly to the user's request — if asked to write content, just write it; only use tools when the task genuinely requires file or command operations.`;
|
|
17
|
+
}
|
|
14
18
|
// App-level (module-scoped) tool registry and permission manager.
|
|
15
19
|
// Created once and shared across all submissions.
|
|
16
20
|
const toolRegistry = createDefaultRegistry();
|
|
@@ -34,7 +38,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
34
38
|
muted: false,
|
|
35
39
|
buffer: "",
|
|
36
40
|
usage: { input: 0, output: 0 },
|
|
37
|
-
contextLimit:
|
|
41
|
+
contextLimit: contextLimitForModel(config.models[m.name]?.provider ?? "", config.models[m.name]?.context_limit),
|
|
38
42
|
})),
|
|
39
43
|
targetMode: saved.lastTarget === "broadcast"
|
|
40
44
|
? { type: "broadcast" }
|
|
@@ -50,8 +54,29 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
50
54
|
const [scrollOffsets, setScrollOffsets] = useState({});
|
|
51
55
|
const [modelStates, setModelStates] = useState(() => session.models);
|
|
52
56
|
const [comparisonModel, setComparisonModel] = useState(null);
|
|
57
|
+
const comparisonFromBroadcastRef = useRef(false);
|
|
58
|
+
// Lightweight trigger for re-renders when only session.targetMode changes (not model data)
|
|
59
|
+
const [targetVersion, setTargetVersion] = useState(0);
|
|
60
|
+
// ── Team / Broadcast mode ──────────────────────────────────────
|
|
61
|
+
const [teamMode, setTeamMode] = useState(false);
|
|
62
|
+
// ── Deliberation state ─────────────────────────────────────────
|
|
63
|
+
const [deliberationProgress, setDeliberationProgress] = useState(null);
|
|
64
|
+
const [deliberationDocument, setDeliberationDocument] = useState("");
|
|
65
|
+
const [deliberationRounds, setDeliberationRounds] = useState([]);
|
|
66
|
+
const [deliberationScrollOffset, setDeliberationScrollOffset] = useState(0);
|
|
67
|
+
const deliberatingRef = useRef(false);
|
|
68
|
+
const deliberationAbortRef = useRef(null);
|
|
69
|
+
// Ref mirroring currentModeState() so the raw stdin Esc listener
|
|
70
|
+
// always reads fresh mode state without re-subscribing on every render.
|
|
71
|
+
const modeStateRef = useRef(currentModeState());
|
|
72
|
+
modeStateRef.current = currentModeState();
|
|
53
73
|
const activeScrollModel = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
54
74
|
const adjustScroll = useCallback((delta) => {
|
|
75
|
+
// Team overview with deliberation content → scroll the deliberation view
|
|
76
|
+
if (teamMode && isOverview() && deliberationProgress) {
|
|
77
|
+
setDeliberationScrollOffset((prev) => Math.max(0, prev + delta));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
55
80
|
const modelName = activeScrollModel;
|
|
56
81
|
if (!modelName)
|
|
57
82
|
return;
|
|
@@ -59,7 +84,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
59
84
|
...prev,
|
|
60
85
|
[modelName]: Math.max(0, (prev[modelName] ?? 0) + delta),
|
|
61
86
|
}));
|
|
62
|
-
}, [activeScrollModel]);
|
|
87
|
+
}, [activeScrollModel, teamMode, deliberationProgress]);
|
|
63
88
|
// Input history
|
|
64
89
|
const inputHistoryRef = useRef([]);
|
|
65
90
|
const historyIdxRef = useRef(-1);
|
|
@@ -93,9 +118,13 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
93
118
|
lastTarget,
|
|
94
119
|
});
|
|
95
120
|
}, [session, sessionId]);
|
|
96
|
-
const targetPrefix =
|
|
97
|
-
? "
|
|
98
|
-
|
|
121
|
+
const targetPrefix = teamMode
|
|
122
|
+
? session.targetMode.type === "broadcast"
|
|
123
|
+
? "team"
|
|
124
|
+
: `team:${session.targetMode.modelName}`
|
|
125
|
+
: session.targetMode.type === "broadcast"
|
|
126
|
+
? "all"
|
|
127
|
+
: session.targetMode.modelName;
|
|
99
128
|
const activeModelName = session.targetMode.type === "broadcast" ? null : session.targetMode.modelName;
|
|
100
129
|
// Save session on process exit (Ctrl+C, kill, etc.)
|
|
101
130
|
useEffect(() => {
|
|
@@ -117,6 +146,63 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
117
146
|
const wm = new WorktreeManager(process.cwd());
|
|
118
147
|
wm.sweepOrphans().catch(() => { });
|
|
119
148
|
}, []);
|
|
149
|
+
// Raw stdin listener for Escape key.
|
|
150
|
+
// Ink's useInput key.escape is unreliable on some terminal setups
|
|
151
|
+
// (Windows Terminal + bash in particular). We listen for the raw
|
|
152
|
+
// \x1b byte directly and use a brief timeout to distinguish
|
|
153
|
+
// standalone Esc from escape sequences (arrow keys, etc.).
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
let escTimer = null;
|
|
156
|
+
const handleEsc = () => {
|
|
157
|
+
const state = modeStateRef.current;
|
|
158
|
+
const r = reduceEscape(state, deliberatingRef.current);
|
|
159
|
+
if (r.teamMode !== state.teamMode)
|
|
160
|
+
setTeamMode(r.teamMode);
|
|
161
|
+
if (r.abortDeliberation) {
|
|
162
|
+
deliberationAbortRef.current?.abort();
|
|
163
|
+
deliberatingRef.current = false;
|
|
164
|
+
}
|
|
165
|
+
if (r.resetDeliberation) {
|
|
166
|
+
setDeliberationProgress(null);
|
|
167
|
+
setDeliberationDocument("");
|
|
168
|
+
setDeliberationRounds([]);
|
|
169
|
+
}
|
|
170
|
+
setComparisonModel(r.comparisonModel);
|
|
171
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
172
|
+
if (r.goToOverview) {
|
|
173
|
+
session.setTarget({ type: "broadcast" });
|
|
174
|
+
setTargetVersion((v) => v + 1);
|
|
175
|
+
}
|
|
176
|
+
if (r.restoreBroadcast) {
|
|
177
|
+
session.setTarget({ type: "broadcast" });
|
|
178
|
+
comparisonFromBroadcastRef.current = false;
|
|
179
|
+
setModelStates([...session.models]);
|
|
180
|
+
}
|
|
181
|
+
shortcutHandledRef.current = true;
|
|
182
|
+
};
|
|
183
|
+
const onData = (data) => {
|
|
184
|
+
// A single 0x1b byte might be standalone Esc or the start of
|
|
185
|
+
// an escape sequence (\x1b[A for Up, etc.). Wait briefly to
|
|
186
|
+
// see if more bytes follow.
|
|
187
|
+
if (data.length === 1 && data[0] === 0x1b) {
|
|
188
|
+
if (escTimer)
|
|
189
|
+
clearTimeout(escTimer);
|
|
190
|
+
escTimer = setTimeout(() => { handleEsc(); escTimer = null; }, 35);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Any other input — cancel pending Esc (it was part of a sequence)
|
|
194
|
+
if (escTimer) {
|
|
195
|
+
clearTimeout(escTimer);
|
|
196
|
+
escTimer = null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
process.stdin.on("data", onData);
|
|
200
|
+
return () => {
|
|
201
|
+
process.stdin.removeListener("data", onData);
|
|
202
|
+
if (escTimer)
|
|
203
|
+
clearTimeout(escTimer);
|
|
204
|
+
};
|
|
205
|
+
}, []);
|
|
120
206
|
// Clear the input bar whenever a shortcut was handled (runs after the render
|
|
121
207
|
// batch so it overrides any concurrent setInput from ink-text-input).
|
|
122
208
|
useEffect(() => {
|
|
@@ -125,23 +211,83 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
125
211
|
setInput("");
|
|
126
212
|
}
|
|
127
213
|
});
|
|
214
|
+
// Build a ModeState snapshot from current React state so the pure
|
|
215
|
+
// decision functions in modeTransitions.ts can drive the keyboard handler.
|
|
216
|
+
function currentModeState() {
|
|
217
|
+
return buildModeState({
|
|
218
|
+
teamMode,
|
|
219
|
+
deliberationProgress,
|
|
220
|
+
comparisonModel,
|
|
221
|
+
comparisonFromBroadcast: comparisonFromBroadcastRef.current,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** True when the current target is the mode overview (broadcast target). */
|
|
225
|
+
function isOverview() {
|
|
226
|
+
return session.targetMode.type === "broadcast";
|
|
227
|
+
}
|
|
128
228
|
// Keyboard input: Tab cycling, scrolling, and single-key shortcuts.
|
|
129
|
-
// Single-key shortcuts (d/m/r) only fire when the input bar is empty so they
|
|
130
|
-
// don't interfere with message typing.
|
|
131
229
|
useInput((inputValue, key) => {
|
|
132
|
-
|
|
133
|
-
|
|
230
|
+
// ── Tab (no shift): cycle target within current mode ──────────
|
|
231
|
+
// Tab never changes teamMode. It cycles: overview → model1 → … → overview.
|
|
232
|
+
if (key.tab && !key.shift) {
|
|
233
|
+
const r = reduceTab(currentModeState());
|
|
234
|
+
if (r.clearComparison) {
|
|
235
|
+
setComparisonModel(null);
|
|
236
|
+
comparisonFromBroadcastRef.current = false;
|
|
237
|
+
}
|
|
238
|
+
if (r.cycleTarget) {
|
|
239
|
+
session.cycleTarget();
|
|
240
|
+
setTargetVersion((v) => v + 1);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// ── Shift+Tab: toggle between broadcast ↔ team ──────────────
|
|
245
|
+
if (key.tab && key.shift) {
|
|
246
|
+
const r = reduceShiftTab(currentModeState(), isOverview());
|
|
247
|
+
if (!r)
|
|
248
|
+
return;
|
|
249
|
+
setTeamMode(r.teamMode);
|
|
134
250
|
setComparisonModel(null);
|
|
135
|
-
|
|
251
|
+
if (r.goToOverview) {
|
|
252
|
+
session.setTarget({ type: "broadcast" });
|
|
253
|
+
setTargetVersion((v) => v + 1);
|
|
254
|
+
}
|
|
255
|
+
if (r.resetDeliberation) {
|
|
256
|
+
setDeliberationProgress(null);
|
|
257
|
+
setDeliberationDocument("");
|
|
258
|
+
setDeliberationRounds([]);
|
|
259
|
+
}
|
|
136
260
|
return;
|
|
137
261
|
}
|
|
138
|
-
// Escape
|
|
262
|
+
// ── Escape: return to current mode's overview ────────────────
|
|
139
263
|
if (key.escape) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
264
|
+
const r = reduceEscape(currentModeState(), deliberatingRef.current);
|
|
265
|
+
// teamMode is preserved — Esc never toggles it
|
|
266
|
+
if (r.teamMode !== teamMode)
|
|
267
|
+
setTeamMode(r.teamMode);
|
|
268
|
+
if (r.abortDeliberation) {
|
|
269
|
+
deliberationAbortRef.current?.abort();
|
|
270
|
+
deliberatingRef.current = false;
|
|
271
|
+
}
|
|
272
|
+
if (r.resetDeliberation) {
|
|
273
|
+
setDeliberationProgress(null);
|
|
274
|
+
setDeliberationDocument("");
|
|
275
|
+
setDeliberationRounds([]);
|
|
276
|
+
}
|
|
277
|
+
setComparisonModel(r.comparisonModel);
|
|
278
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
279
|
+
if (r.goToOverview) {
|
|
280
|
+
session.setTarget({ type: "broadcast" });
|
|
281
|
+
setTargetVersion((v) => v + 1);
|
|
144
282
|
}
|
|
283
|
+
if (r.restoreBroadcast) {
|
|
284
|
+
// Exiting comparison that entered from broadcast — restore broadcast
|
|
285
|
+
session.setTarget({ type: "broadcast" });
|
|
286
|
+
comparisonFromBroadcastRef.current = false;
|
|
287
|
+
setModelStates([...session.models]);
|
|
288
|
+
}
|
|
289
|
+
shortcutHandledRef.current = true;
|
|
290
|
+
return;
|
|
145
291
|
}
|
|
146
292
|
if (key.upArrow) {
|
|
147
293
|
if (input.length === 0) {
|
|
@@ -185,21 +331,20 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
185
331
|
return;
|
|
186
332
|
// 'd' — toggle comparison mode (current model vs next unmuted model)
|
|
187
333
|
if (inputValue === "d") {
|
|
188
|
-
|
|
189
|
-
|
|
334
|
+
const unmutedNames = session.models.filter((m) => !m.muted).map((m) => m.name);
|
|
335
|
+
const currentTarget = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
336
|
+
const r = reduceKeyD(currentModeState(), unmutedNames, currentTarget);
|
|
337
|
+
// Exiting comparison — restore broadcast if we entered from there
|
|
338
|
+
if (comparisonModel && !r.comparisonModel && comparisonFromBroadcastRef.current) {
|
|
339
|
+
session.setTarget({ type: "broadcast" });
|
|
340
|
+
comparisonFromBroadcastRef.current = false;
|
|
341
|
+
setTargetVersion((v) => v + 1);
|
|
190
342
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (baseName && unmuted.length >= 2) {
|
|
197
|
-
const idx = unmuted.findIndex((m) => m.name === baseName);
|
|
198
|
-
const next = unmuted[(idx + 1) % unmuted.length];
|
|
199
|
-
if (next && next.name !== baseName) {
|
|
200
|
-
setComparisonModel(next.name);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
343
|
+
setComparisonModel(r.comparisonModel);
|
|
344
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
345
|
+
if (r.setDirectedTarget) {
|
|
346
|
+
session.setTarget({ type: "directed", modelName: r.setDirectedTarget });
|
|
347
|
+
setTargetVersion((v) => v + 1);
|
|
203
348
|
}
|
|
204
349
|
shortcutHandledRef.current = true;
|
|
205
350
|
return;
|
|
@@ -210,6 +355,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
210
355
|
session.toggleMute(session.targetMode.modelName);
|
|
211
356
|
setModelStates([...session.models]);
|
|
212
357
|
setComparisonModel(null);
|
|
358
|
+
comparisonFromBroadcastRef.current = false;
|
|
213
359
|
}
|
|
214
360
|
shortcutHandledRef.current = true;
|
|
215
361
|
return;
|
|
@@ -230,10 +376,240 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
230
376
|
return;
|
|
231
377
|
}
|
|
232
378
|
});
|
|
379
|
+
// ── Deliberation runner ───────────────────────────────────────
|
|
380
|
+
const runDeliberationPipeline = useCallback(async (task) => {
|
|
381
|
+
if (deliberatingRef.current)
|
|
382
|
+
return;
|
|
383
|
+
deliberatingRef.current = true;
|
|
384
|
+
const activeModels = session.models
|
|
385
|
+
.filter((m) => !m.muted)
|
|
386
|
+
.map((m) => m.name);
|
|
387
|
+
if (activeModels.length < 2) {
|
|
388
|
+
// Need at least 2 models for deliberation
|
|
389
|
+
setDeliberationProgress({
|
|
390
|
+
type: "error",
|
|
391
|
+
round: 0,
|
|
392
|
+
totalRounds: 0,
|
|
393
|
+
error: "Deliberation requires at least 2 active (non-muted) models.",
|
|
394
|
+
});
|
|
395
|
+
deliberatingRef.current = false;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Use config deliberation settings or auto-assign
|
|
399
|
+
const delibConfig = config.deliberation;
|
|
400
|
+
let roundConfigs;
|
|
401
|
+
if (delibConfig?.rounds && delibConfig.rounds.length > 0) {
|
|
402
|
+
roundConfigs = delibConfig.rounds
|
|
403
|
+
.filter((r) => activeModels.includes(r.model) && config.models[r.model])
|
|
404
|
+
.map((r) => ({
|
|
405
|
+
modelName: r.model,
|
|
406
|
+
role: r.role,
|
|
407
|
+
config: config.models[r.model],
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
roundConfigs = autoAssignRounds(activeModels, config.models);
|
|
412
|
+
}
|
|
413
|
+
if (roundConfigs.length < 2) {
|
|
414
|
+
setDeliberationProgress({
|
|
415
|
+
type: "error",
|
|
416
|
+
round: 0,
|
|
417
|
+
totalRounds: 0,
|
|
418
|
+
error: "Not enough configured models for deliberation (need ≥2).",
|
|
419
|
+
});
|
|
420
|
+
deliberatingRef.current = false;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
// Load constraint document if configured
|
|
424
|
+
let constraint;
|
|
425
|
+
if (delibConfig?.constraint_file) {
|
|
426
|
+
try {
|
|
427
|
+
constraint = await readFile(delibConfig.constraint_file, "utf-8");
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Constraint file not found — proceed without it
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
setDeliberationDocument("");
|
|
434
|
+
setDeliberationRounds([]);
|
|
435
|
+
setDeliberationScrollOffset(0);
|
|
436
|
+
let doc = "";
|
|
437
|
+
const stream = runDeliberation(task, roundConfigs, constraint);
|
|
438
|
+
for await (const event of stream) {
|
|
439
|
+
setDeliberationProgress(event);
|
|
440
|
+
if (event.type === "round_start") {
|
|
441
|
+
doc = "";
|
|
442
|
+
setDeliberationDocument("");
|
|
443
|
+
setDeliberationRounds((prev) => [
|
|
444
|
+
...prev,
|
|
445
|
+
{ round: event.round, modelName: event.modelName, role: event.role },
|
|
446
|
+
]);
|
|
447
|
+
}
|
|
448
|
+
else if (event.type === "text" && event.content) {
|
|
449
|
+
doc += event.content;
|
|
450
|
+
setDeliberationDocument(doc);
|
|
451
|
+
}
|
|
452
|
+
else if (event.type === "round_end") {
|
|
453
|
+
// Update the round entry with change metadata
|
|
454
|
+
setDeliberationRounds((prev) => prev.map((r) => r.round === event.round
|
|
455
|
+
? {
|
|
456
|
+
...r,
|
|
457
|
+
changeCount: event.changeCount,
|
|
458
|
+
changeSamples: event.changeSamples,
|
|
459
|
+
}
|
|
460
|
+
: r));
|
|
461
|
+
}
|
|
462
|
+
else if (event.type === "done") {
|
|
463
|
+
setDeliberationDocument(event.document ?? doc);
|
|
464
|
+
}
|
|
465
|
+
else if (event.type === "error") {
|
|
466
|
+
deliberatingRef.current = false;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Keep the final document visible; user presses Esc to dismiss
|
|
471
|
+
// Inject the final document into each model's message history
|
|
472
|
+
// so they can discuss it when the user switches to chat mode.
|
|
473
|
+
if (doc) {
|
|
474
|
+
const contextMsg = `[团队审议结果]\n\n以下是你与其他模型协作完成的最终文档。用户可以就此文档与你讨论。\n\n---\n${doc}\n---`;
|
|
475
|
+
for (const m of session.models) {
|
|
476
|
+
if (!m.muted) {
|
|
477
|
+
m.messages.push({ role: "user", content: contextMsg });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
deliberatingRef.current = false;
|
|
482
|
+
}, [session.models, config]);
|
|
483
|
+
// ── Merge runner ──────────────────────────────────────────────
|
|
484
|
+
const runMergePipeline = useCallback(async () => {
|
|
485
|
+
if (deliberatingRef.current)
|
|
486
|
+
return;
|
|
487
|
+
deliberatingRef.current = true;
|
|
488
|
+
// Collect the last assistant response from each non-muted model
|
|
489
|
+
const outputs = [];
|
|
490
|
+
for (const m of session.models) {
|
|
491
|
+
if (m.muted)
|
|
492
|
+
continue;
|
|
493
|
+
// Find the last assistant message
|
|
494
|
+
const lastAssistant = [...m.messages].reverse().find((msg) => msg.role === "assistant");
|
|
495
|
+
if (lastAssistant?.content) {
|
|
496
|
+
outputs.push({ modelName: m.name, content: lastAssistant.content });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (outputs.length < 2) {
|
|
500
|
+
setDeliberationProgress({
|
|
501
|
+
type: "error",
|
|
502
|
+
round: 0,
|
|
503
|
+
totalRounds: 0,
|
|
504
|
+
error: "需要至少 2 个模型有回复才能合并。",
|
|
505
|
+
});
|
|
506
|
+
deliberatingRef.current = false;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Find the last user message as the task
|
|
510
|
+
const firstModel = session.models.find((m) => !m.muted);
|
|
511
|
+
const lastUserMsg = firstModel?.messages
|
|
512
|
+
? [...firstModel.messages].reverse().find((m) => m.role === "user")
|
|
513
|
+
: null;
|
|
514
|
+
const task = lastUserMsg?.content ?? "合并以下模型输出";
|
|
515
|
+
// Use the first non-muted model as the merger
|
|
516
|
+
const mergerName = session.models.find((m) => !m.muted).name;
|
|
517
|
+
const mergerConfig = config.models[mergerName];
|
|
518
|
+
if (!mergerConfig) {
|
|
519
|
+
setDeliberationProgress({
|
|
520
|
+
type: "error",
|
|
521
|
+
round: 0,
|
|
522
|
+
totalRounds: 0,
|
|
523
|
+
error: `未找到模型 "${mergerName}" 的配置。`,
|
|
524
|
+
});
|
|
525
|
+
deliberatingRef.current = false;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
setDeliberationDocument("");
|
|
529
|
+
setDeliberationRounds([]);
|
|
530
|
+
setDeliberationScrollOffset(0);
|
|
531
|
+
let doc = "";
|
|
532
|
+
const stream = runMerge(task, outputs, mergerConfig, mergerName);
|
|
533
|
+
for await (const event of stream) {
|
|
534
|
+
setDeliberationProgress(event);
|
|
535
|
+
if (event.type === "round_start") {
|
|
536
|
+
doc = "";
|
|
537
|
+
setDeliberationDocument("");
|
|
538
|
+
setDeliberationRounds([
|
|
539
|
+
{ round: 1, modelName: mergerName, role: "draft" },
|
|
540
|
+
]);
|
|
541
|
+
}
|
|
542
|
+
else if (event.type === "text" && event.content) {
|
|
543
|
+
doc += event.content;
|
|
544
|
+
setDeliberationDocument(doc);
|
|
545
|
+
}
|
|
546
|
+
else if (event.type === "done") {
|
|
547
|
+
setDeliberationDocument(event.document ?? doc);
|
|
548
|
+
}
|
|
549
|
+
else if (event.type === "error") {
|
|
550
|
+
deliberatingRef.current = false;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Inject the merged document into each model's history so they
|
|
555
|
+
// can discuss it when the user switches to chat mode.
|
|
556
|
+
if (doc) {
|
|
557
|
+
const contextMsg = `[合并结果]\n\n以下是将各模型输出合并后的最终文档。用户可以就此文档与你讨论。\n\n---\n${doc}\n---`;
|
|
558
|
+
for (const m of session.models) {
|
|
559
|
+
if (!m.muted) {
|
|
560
|
+
m.messages.push({ role: "user", content: contextMsg });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
deliberatingRef.current = false;
|
|
565
|
+
}, [session.models, config]);
|
|
233
566
|
const handleSubmit = useCallback(async (value) => {
|
|
234
567
|
const trimmed = value.trim();
|
|
235
568
|
if (!trimmed)
|
|
236
569
|
return;
|
|
570
|
+
// ── Team mode: submit runs deliberation or routes to model ──
|
|
571
|
+
if (teamMode) {
|
|
572
|
+
const r = reduceSubmitInTeam(currentModeState(), isOverview());
|
|
573
|
+
if (r.action === "block")
|
|
574
|
+
return;
|
|
575
|
+
if (r.action === "deliberate") {
|
|
576
|
+
inputHistoryRef.current.push(trimmed);
|
|
577
|
+
historyIdxRef.current = -1;
|
|
578
|
+
setInput("");
|
|
579
|
+
setDeliberationDocument("");
|
|
580
|
+
// Reset target to overview so OutputArea shows deliberation progress
|
|
581
|
+
session.setTarget({ type: "broadcast" });
|
|
582
|
+
setTargetVersion((v) => v + 1);
|
|
583
|
+
runDeliberationPipeline(trimmed);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
// r.action === "route_normally" — send as directed/broadcast message
|
|
587
|
+
}
|
|
588
|
+
// ── Team mode toggle ────────────────────────────────────
|
|
589
|
+
if (trimmed === "/team" || trimmed === "/t") {
|
|
590
|
+
inputHistoryRef.current.push(trimmed);
|
|
591
|
+
historyIdxRef.current = -1;
|
|
592
|
+
setInput("");
|
|
593
|
+
setTeamMode((prev) => !prev);
|
|
594
|
+
// Entering a mode always lands on its overview
|
|
595
|
+
session.setTarget({ type: "broadcast" });
|
|
596
|
+
setTargetVersion((v) => v + 1);
|
|
597
|
+
setDeliberationProgress(null);
|
|
598
|
+
setDeliberationDocument("");
|
|
599
|
+
setDeliberationRounds([]);
|
|
600
|
+
setComparisonModel(null);
|
|
601
|
+
shortcutHandledRef.current = true;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// ── Merge command: synthesize last outputs ───────────────
|
|
605
|
+
if (trimmed === "/merge" || trimmed === "/m") {
|
|
606
|
+
inputHistoryRef.current.push(trimmed);
|
|
607
|
+
historyIdxRef.current = -1;
|
|
608
|
+
setInput("");
|
|
609
|
+
setDeliberationDocument("");
|
|
610
|
+
runMergePipeline();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
237
613
|
// Add to input history
|
|
238
614
|
inputHistoryRef.current.push(trimmed);
|
|
239
615
|
historyIdxRef.current = -1;
|
|
@@ -272,7 +648,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
272
648
|
modelName: m.name,
|
|
273
649
|
config: mc,
|
|
274
650
|
messages: m.messages,
|
|
275
|
-
systemPrompt:
|
|
651
|
+
systemPrompt: makeSystemPrompt(m.name, mc.provider),
|
|
276
652
|
tools: toolRegistry.getDefinitions(),
|
|
277
653
|
registry: toolRegistry,
|
|
278
654
|
permission: permissionManager,
|
|
@@ -298,12 +674,8 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
298
674
|
await worktreeManager.cleanup(taskId);
|
|
299
675
|
// ── Auto-save session ─────────────────────────────────────
|
|
300
676
|
saveCurrentSession();
|
|
301
|
-
}, [session, config, sessionId, saveCurrentSession]);
|
|
677
|
+
}, [session, config, sessionId, saveCurrentSession, teamMode, deliberationProgress, runDeliberationPipeline, setTargetVersion]);
|
|
302
678
|
const terminalWidth = process.stdout.columns ?? 80;
|
|
303
|
-
const contextUsages = {};
|
|
304
|
-
for (const m of modelStates) {
|
|
305
|
-
contextUsages[m.name] = session.getContextUsage(m.name);
|
|
306
|
-
}
|
|
307
679
|
// ── No models configured: show startup guide ──────────────────
|
|
308
680
|
if (modelStates.length === 0) {
|
|
309
681
|
const example = `[models.claude]
|
|
@@ -316,28 +688,36 @@ provider = "openai"
|
|
|
316
688
|
model = "gpt-4o"
|
|
317
689
|
api_key = "\${OPENAI_API_KEY}"
|
|
318
690
|
|
|
691
|
+
[models.deepseek]
|
|
692
|
+
provider = "deepseek"
|
|
693
|
+
model = "deepseek-chat"
|
|
694
|
+
api_key = "\${DEEPSEEK_API_KEY}"
|
|
695
|
+
|
|
696
|
+
[models.minimax]
|
|
697
|
+
provider = "minimax"
|
|
698
|
+
model = "MiniMax-M2.1"
|
|
699
|
+
api_key = "\${MINIMAX_API_KEY}"
|
|
700
|
+
|
|
319
701
|
[defaults]
|
|
320
702
|
active = ["claude", "gpt"]
|
|
321
703
|
broadcast = true`;
|
|
322
704
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
323
|
-
React.createElement(Text, { bold: true, color: "cyan" }, "
|
|
705
|
+
React.createElement(Text, { bold: true, color: "cyan" }, "multiarena \u2014 Multi-Model AI Coding Assistant"),
|
|
324
706
|
React.createElement(Text, null, " "),
|
|
325
707
|
React.createElement(Text, null,
|
|
326
708
|
"No models configured. Create a ",
|
|
327
|
-
React.createElement(Text, { color: "yellow" }, ".
|
|
709
|
+
React.createElement(Text, { color: "yellow" }, ".multiarenarc"),
|
|
328
710
|
" file in your project root or home directory:"),
|
|
329
711
|
React.createElement(Text, null, " "),
|
|
330
712
|
React.createElement(Text, { color: "gray" }, example),
|
|
331
713
|
React.createElement(Text, null, " "),
|
|
332
|
-
React.createElement(Text, { dimColor: true }, "Supported providers: anthropic, openai, google, ollama")));
|
|
714
|
+
React.createElement(Text, { dimColor: true }, "Supported providers: anthropic, openai, google, ollama, deepseek, minimax")));
|
|
333
715
|
}
|
|
334
716
|
return (React.createElement(Box, { flexDirection: "column", width: "100%" },
|
|
335
|
-
React.createElement(StatusBar, { models: modelStates, activeModelName: activeModelName, contextUsages: contextUsages }),
|
|
336
717
|
configWarnings.length > 0 && (React.createElement(Box, { flexDirection: "column" }, configWarnings.map((w, i) => (React.createElement(Text, { key: i, color: "yellow" },
|
|
337
718
|
"\u26A0 ",
|
|
338
719
|
w.message))))),
|
|
339
|
-
React.createElement(
|
|
340
|
-
|
|
341
|
-
React.createElement(
|
|
342
|
-
React.createElement(InputBar, { prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
|
|
720
|
+
React.createElement(Box, { flexGrow: 1 },
|
|
721
|
+
React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel, terminalWidth: terminalWidth, deliberationProgress: deliberationProgress, deliberationDocument: deliberationDocument, deliberationRounds: deliberationRounds, teamMode: teamMode, deliberationScrollOffset: deliberationScrollOffset })),
|
|
722
|
+
React.createElement(InputBar, { models: modelStates, activeModelName: activeModelName, prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
|
|
343
723
|
};
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { formatTokens } from "./formatTokens.js";
|
|
3
4
|
const PANEL_LINES = 4;
|
|
4
|
-
export const BroadcastSummary = ({ models }) => {
|
|
5
|
+
export const BroadcastSummary = ({ models, terminalWidth }) => {
|
|
5
6
|
const activeModels = models.filter((m) => !m.muted);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
7
|
+
const panelWidth = Math.floor(terminalWidth / activeModels.length);
|
|
8
|
+
return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 }, activeModels.map((m, idx) => {
|
|
9
|
+
const rawLines = m.buffer ? m.buffer.split("\n") : [];
|
|
10
|
+
const totalLines = rawLines.length;
|
|
11
|
+
const isEmpty = totalLines === 0 || (totalLines === 1 && rawLines[0].trim() === "");
|
|
12
|
+
const displayLines = rawLines.slice(-PANEL_LINES);
|
|
13
|
+
while (displayLines.length < PANEL_LINES) {
|
|
14
|
+
displayLines.unshift("");
|
|
15
|
+
}
|
|
16
|
+
const isLast = idx === activeModels.length - 1;
|
|
17
|
+
return (React.createElement(Box, { key: m.name, flexDirection: "column", width: panelWidth, borderStyle: "single", borderColor: "gray", marginRight: isLast ? 0 : 1 },
|
|
10
18
|
React.createElement(Text, { bold: true }, m.name),
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
displayLines.map((line, i) => {
|
|
20
|
+
if (isEmpty && i === 0) {
|
|
21
|
+
return (React.createElement(Text, { key: i, dimColor: true }, m.isStreaming ? "Waiting..." : "No output"));
|
|
22
|
+
}
|
|
23
|
+
return (React.createElement(Text, { key: i, wrap: "truncate" }, line || " "));
|
|
24
|
+
}),
|
|
13
25
|
React.createElement(Text, { dimColor: true },
|
|
14
|
-
totalLines,
|
|
26
|
+
isEmpty ? 0 : totalLines,
|
|
15
27
|
" lines \u00B7 ",
|
|
28
|
+
formatTokens(m.usage.input + m.usage.output),
|
|
29
|
+
"/",
|
|
30
|
+
formatTokens(m.contextLimit),
|
|
31
|
+
" \u00B7 ",
|
|
16
32
|
m.isStreaming ? "streaming..." : "done")));
|
|
17
33
|
})));
|
|
18
34
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { DeliberationProgress, RoundRole } from "../../core/deliberation.js";
|
|
3
|
+
export interface RoundSummary {
|
|
4
|
+
round: number;
|
|
5
|
+
modelName: string;
|
|
6
|
+
role: RoundRole;
|
|
7
|
+
changeCount?: number;
|
|
8
|
+
changeSamples?: string[];
|
|
9
|
+
}
|
|
10
|
+
interface Props {
|
|
11
|
+
progress: DeliberationProgress;
|
|
12
|
+
document: string;
|
|
13
|
+
rounds: RoundSummary[];
|
|
14
|
+
scrollOffset?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const DeliberationView: React.FC<Props>;
|
|
17
|
+
export {};
|