multiarena 0.1.1 → 0.1.4
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 +152 -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/types.d.ts +10 -0
- package/dist/core/deliberation.d.ts +54 -0
- package/dist/core/deliberation.js +475 -0
- package/dist/core/session.d.ts +3 -1
- package/dist/core/session.js +11 -4
- package/dist/index.js +3 -49
- package/dist/provider/adapters/openai.d.ts +15 -0
- package/dist/provider/adapters/openai.js +63 -49
- package/dist/ui/app.js +459 -53
- package/dist/ui/components/BroadcastSummary.js +1 -1
- package/dist/ui/components/DeliberationView.d.ts +18 -0
- package/dist/ui/components/DeliberationView.js +87 -0
- package/dist/ui/components/InputBar.js +1 -1
- package/dist/ui/components/ModelDetail.js +1 -1
- package/dist/ui/components/OutputArea.d.ts +8 -0
- package/dist/ui/components/OutputArea.js +31 -1
- 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 -10
- package/dist/ui/components/StatusBar.js +0 -50
package/dist/ui/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { OutputArea } from "./components/OutputArea.js";
|
|
4
5
|
import { InputBar } from "./components/InputBar.js";
|
|
5
6
|
import { Session, contextLimitForModel } from "../core/session.js";
|
|
@@ -9,8 +10,10 @@ import { PermissionManager } from "../tools/permission.js";
|
|
|
9
10
|
import { runTurn } from "../core/turn.js";
|
|
10
11
|
import { WorktreeManager } from "../isolation/worktree.js";
|
|
11
12
|
import { saveSession, loadSession } from "../persistence/session.js";
|
|
13
|
+
import { runDeliberation, runMerge, autoAssignRounds, } from "../core/deliberation.js";
|
|
14
|
+
import { reduceTab, reduceShiftTab, reduceEscape, reduceKeyD, reduceSubmitInTeam, buildModeState, } from "./modeTransitions.js";
|
|
12
15
|
function makeSystemPrompt(modelName, provider) {
|
|
13
|
-
return `You are a helpful AI coding
|
|
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.`;
|
|
14
17
|
}
|
|
15
18
|
// App-level (module-scoped) tool registry and permission manager.
|
|
16
19
|
// Created once and shared across all submissions.
|
|
@@ -52,8 +55,29 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
52
55
|
const [modelStates, setModelStates] = useState(() => session.models);
|
|
53
56
|
const [comparisonModel, setComparisonModel] = useState(null);
|
|
54
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 [deliberationThinkText, setDeliberationThinkText] = useState("");
|
|
66
|
+
const [deliberationRounds, setDeliberationRounds] = useState([]);
|
|
67
|
+
const [deliberationScrollOffset, setDeliberationScrollOffset] = useState(0);
|
|
68
|
+
const deliberatingRef = useRef(false);
|
|
69
|
+
const deliberationAbortRef = useRef(null);
|
|
70
|
+
// Ref mirroring currentModeState() so the raw stdin Esc listener
|
|
71
|
+
// always reads fresh mode state without re-subscribing on every render.
|
|
72
|
+
const modeStateRef = useRef(currentModeState());
|
|
73
|
+
modeStateRef.current = currentModeState();
|
|
55
74
|
const activeScrollModel = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
56
75
|
const adjustScroll = useCallback((delta) => {
|
|
76
|
+
// Team overview with deliberation content → scroll the deliberation view
|
|
77
|
+
if (teamMode && isOverview() && deliberationProgress) {
|
|
78
|
+
setDeliberationScrollOffset((prev) => Math.max(0, prev + delta));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
57
81
|
const modelName = activeScrollModel;
|
|
58
82
|
if (!modelName)
|
|
59
83
|
return;
|
|
@@ -61,7 +85,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
61
85
|
...prev,
|
|
62
86
|
[modelName]: Math.max(0, (prev[modelName] ?? 0) + delta),
|
|
63
87
|
}));
|
|
64
|
-
}, [activeScrollModel]);
|
|
88
|
+
}, [activeScrollModel, teamMode, deliberationProgress]);
|
|
65
89
|
// Input history
|
|
66
90
|
const inputHistoryRef = useRef([]);
|
|
67
91
|
const historyIdxRef = useRef(-1);
|
|
@@ -95,9 +119,13 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
95
119
|
lastTarget,
|
|
96
120
|
});
|
|
97
121
|
}, [session, sessionId]);
|
|
98
|
-
const targetPrefix =
|
|
99
|
-
? "
|
|
100
|
-
|
|
122
|
+
const targetPrefix = teamMode
|
|
123
|
+
? session.targetMode.type === "broadcast"
|
|
124
|
+
? "team"
|
|
125
|
+
: `team:${session.targetMode.modelName}`
|
|
126
|
+
: session.targetMode.type === "broadcast"
|
|
127
|
+
? "all"
|
|
128
|
+
: session.targetMode.modelName;
|
|
101
129
|
const activeModelName = session.targetMode.type === "broadcast" ? null : session.targetMode.modelName;
|
|
102
130
|
// Save session on process exit (Ctrl+C, kill, etc.)
|
|
103
131
|
useEffect(() => {
|
|
@@ -119,6 +147,63 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
119
147
|
const wm = new WorktreeManager(process.cwd());
|
|
120
148
|
wm.sweepOrphans().catch(() => { });
|
|
121
149
|
}, []);
|
|
150
|
+
// Raw stdin listener for Escape key.
|
|
151
|
+
// Ink's useInput key.escape is unreliable on some terminal setups
|
|
152
|
+
// (Windows Terminal + bash in particular). We listen for the raw
|
|
153
|
+
// \x1b byte directly and use a brief timeout to distinguish
|
|
154
|
+
// standalone Esc from escape sequences (arrow keys, etc.).
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
let escTimer = null;
|
|
157
|
+
const handleEsc = () => {
|
|
158
|
+
const state = modeStateRef.current;
|
|
159
|
+
const r = reduceEscape(state, deliberatingRef.current);
|
|
160
|
+
if (r.teamMode !== state.teamMode)
|
|
161
|
+
setTeamMode(r.teamMode);
|
|
162
|
+
if (r.abortDeliberation) {
|
|
163
|
+
deliberationAbortRef.current?.abort();
|
|
164
|
+
deliberatingRef.current = false;
|
|
165
|
+
}
|
|
166
|
+
if (r.resetDeliberation) {
|
|
167
|
+
setDeliberationProgress(null);
|
|
168
|
+
setDeliberationDocument("");
|
|
169
|
+
setDeliberationRounds([]);
|
|
170
|
+
}
|
|
171
|
+
setComparisonModel(r.comparisonModel);
|
|
172
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
173
|
+
if (r.goToOverview) {
|
|
174
|
+
session.setTarget({ type: "broadcast" });
|
|
175
|
+
setTargetVersion((v) => v + 1);
|
|
176
|
+
}
|
|
177
|
+
if (r.restoreBroadcast) {
|
|
178
|
+
session.setTarget({ type: "broadcast" });
|
|
179
|
+
comparisonFromBroadcastRef.current = false;
|
|
180
|
+
setModelStates([...session.models]);
|
|
181
|
+
}
|
|
182
|
+
shortcutHandledRef.current = true;
|
|
183
|
+
};
|
|
184
|
+
const onData = (data) => {
|
|
185
|
+
// A single 0x1b byte might be standalone Esc or the start of
|
|
186
|
+
// an escape sequence (\x1b[A for Up, etc.). Wait briefly to
|
|
187
|
+
// see if more bytes follow.
|
|
188
|
+
if (data.length === 1 && data[0] === 0x1b) {
|
|
189
|
+
if (escTimer)
|
|
190
|
+
clearTimeout(escTimer);
|
|
191
|
+
escTimer = setTimeout(() => { handleEsc(); escTimer = null; }, 35);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Any other input — cancel pending Esc (it was part of a sequence)
|
|
195
|
+
if (escTimer) {
|
|
196
|
+
clearTimeout(escTimer);
|
|
197
|
+
escTimer = null;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
process.stdin.on("data", onData);
|
|
201
|
+
return () => {
|
|
202
|
+
process.stdin.removeListener("data", onData);
|
|
203
|
+
if (escTimer)
|
|
204
|
+
clearTimeout(escTimer);
|
|
205
|
+
};
|
|
206
|
+
}, []);
|
|
122
207
|
// Clear the input bar whenever a shortcut was handled (runs after the render
|
|
123
208
|
// batch so it overrides any concurrent setInput from ink-text-input).
|
|
124
209
|
useEffect(() => {
|
|
@@ -127,29 +212,83 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
127
212
|
setInput("");
|
|
128
213
|
}
|
|
129
214
|
});
|
|
215
|
+
// Build a ModeState snapshot from current React state so the pure
|
|
216
|
+
// decision functions in modeTransitions.ts can drive the keyboard handler.
|
|
217
|
+
function currentModeState() {
|
|
218
|
+
return buildModeState({
|
|
219
|
+
teamMode,
|
|
220
|
+
deliberationProgress,
|
|
221
|
+
comparisonModel,
|
|
222
|
+
comparisonFromBroadcast: comparisonFromBroadcastRef.current,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** True when the current target is the mode overview (broadcast target). */
|
|
226
|
+
function isOverview() {
|
|
227
|
+
return session.targetMode.type === "broadcast";
|
|
228
|
+
}
|
|
130
229
|
// Keyboard input: Tab cycling, scrolling, and single-key shortcuts.
|
|
131
|
-
// Single-key shortcuts (d/m/r) only fire when the input bar is empty so they
|
|
132
|
-
// don't interfere with message typing.
|
|
133
230
|
useInput((inputValue, key) => {
|
|
134
|
-
|
|
135
|
-
|
|
231
|
+
// ── Tab (no shift): cycle target within current mode ──────────
|
|
232
|
+
// Tab never changes teamMode. It cycles: overview → model1 → … → overview.
|
|
233
|
+
if (key.tab && !key.shift) {
|
|
234
|
+
const r = reduceTab(currentModeState());
|
|
235
|
+
if (r.clearComparison) {
|
|
236
|
+
setComparisonModel(null);
|
|
237
|
+
comparisonFromBroadcastRef.current = false;
|
|
238
|
+
}
|
|
239
|
+
if (r.cycleTarget) {
|
|
240
|
+
session.cycleTarget();
|
|
241
|
+
setTargetVersion((v) => v + 1);
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// ── Shift+Tab: toggle between broadcast ↔ team ──────────────
|
|
246
|
+
if (key.tab && key.shift) {
|
|
247
|
+
const r = reduceShiftTab(currentModeState(), isOverview());
|
|
248
|
+
if (!r)
|
|
249
|
+
return;
|
|
250
|
+
setTeamMode(r.teamMode);
|
|
136
251
|
setComparisonModel(null);
|
|
137
|
-
|
|
138
|
-
|
|
252
|
+
if (r.goToOverview) {
|
|
253
|
+
session.setTarget({ type: "broadcast" });
|
|
254
|
+
setTargetVersion((v) => v + 1);
|
|
255
|
+
}
|
|
256
|
+
if (r.resetDeliberation) {
|
|
257
|
+
setDeliberationProgress(null);
|
|
258
|
+
setDeliberationDocument("");
|
|
259
|
+
setDeliberationRounds([]);
|
|
260
|
+
}
|
|
139
261
|
return;
|
|
140
262
|
}
|
|
141
|
-
// Escape
|
|
263
|
+
// ── Escape: return to current mode's overview ────────────────
|
|
142
264
|
if (key.escape) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
265
|
+
const r = reduceEscape(currentModeState(), deliberatingRef.current);
|
|
266
|
+
// teamMode is preserved — Esc never toggles it
|
|
267
|
+
if (r.teamMode !== teamMode)
|
|
268
|
+
setTeamMode(r.teamMode);
|
|
269
|
+
if (r.abortDeliberation) {
|
|
270
|
+
deliberationAbortRef.current?.abort();
|
|
271
|
+
deliberatingRef.current = false;
|
|
272
|
+
}
|
|
273
|
+
if (r.resetDeliberation) {
|
|
274
|
+
setDeliberationProgress(null);
|
|
275
|
+
setDeliberationDocument("");
|
|
276
|
+
setDeliberationRounds([]);
|
|
277
|
+
}
|
|
278
|
+
setComparisonModel(r.comparisonModel);
|
|
279
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
280
|
+
if (r.goToOverview) {
|
|
281
|
+
session.setTarget({ type: "broadcast" });
|
|
282
|
+
setTargetVersion((v) => v + 1);
|
|
283
|
+
}
|
|
284
|
+
if (r.restoreBroadcast) {
|
|
285
|
+
// Exiting comparison that entered from broadcast — restore broadcast
|
|
286
|
+
session.setTarget({ type: "broadcast" });
|
|
287
|
+
comparisonFromBroadcastRef.current = false;
|
|
149
288
|
setModelStates([...session.models]);
|
|
150
|
-
shortcutHandledRef.current = true;
|
|
151
|
-
return;
|
|
152
289
|
}
|
|
290
|
+
shortcutHandledRef.current = true;
|
|
291
|
+
return;
|
|
153
292
|
}
|
|
154
293
|
if (key.upArrow) {
|
|
155
294
|
if (input.length === 0) {
|
|
@@ -193,37 +332,21 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
193
332
|
return;
|
|
194
333
|
// 'd' — toggle comparison mode (current model vs next unmuted model)
|
|
195
334
|
if (inputValue === "d") {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
335
|
+
const unmutedNames = session.models.filter((m) => !m.muted).map((m) => m.name);
|
|
336
|
+
const currentTarget = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
337
|
+
const r = reduceKeyD(currentModeState(), unmutedNames, currentTarget);
|
|
338
|
+
// Exiting comparison — restore broadcast if we entered from there
|
|
339
|
+
if (comparisonModel && !r.comparisonModel && comparisonFromBroadcastRef.current) {
|
|
340
|
+
session.setTarget({ type: "broadcast" });
|
|
341
|
+
comparisonFromBroadcastRef.current = false;
|
|
342
|
+
setTargetVersion((v) => v + 1);
|
|
203
343
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
if (session.targetMode.type === "broadcast") {
|
|
211
|
-
// From broadcast: switch to directed for the first model, compare with second
|
|
212
|
-
session.setTarget({ type: "directed", modelName: unmuted[0].name });
|
|
213
|
-
setComparisonModel(unmuted[1].name);
|
|
214
|
-
comparisonFromBroadcastRef.current = true;
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
// From directed: toggle comparison on/off for the current model
|
|
218
|
-
const baseName = session.targetMode.modelName;
|
|
219
|
-
const idx = unmuted.findIndex((m) => m.name === baseName);
|
|
220
|
-
const next = unmuted[(idx + 1) % unmuted.length];
|
|
221
|
-
if (next && next.name !== baseName) {
|
|
222
|
-
setComparisonModel(next.name);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
344
|
+
setComparisonModel(r.comparisonModel);
|
|
345
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
346
|
+
if (r.setDirectedTarget) {
|
|
347
|
+
session.setTarget({ type: "directed", modelName: r.setDirectedTarget });
|
|
348
|
+
setTargetVersion((v) => v + 1);
|
|
225
349
|
}
|
|
226
|
-
setModelStates([...session.models]);
|
|
227
350
|
shortcutHandledRef.current = true;
|
|
228
351
|
return;
|
|
229
352
|
}
|
|
@@ -254,10 +377,294 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
254
377
|
return;
|
|
255
378
|
}
|
|
256
379
|
});
|
|
380
|
+
// ── Deliberation runner ───────────────────────────────────────
|
|
381
|
+
const runDeliberationPipeline = useCallback(async () => {
|
|
382
|
+
if (deliberatingRef.current)
|
|
383
|
+
return;
|
|
384
|
+
deliberatingRef.current = true;
|
|
385
|
+
const activeModels = session.models
|
|
386
|
+
.filter((m) => !m.muted)
|
|
387
|
+
.map((m) => m.name);
|
|
388
|
+
if (activeModels.length < 2) {
|
|
389
|
+
// Need at least 2 models for deliberation
|
|
390
|
+
setDeliberationProgress({
|
|
391
|
+
type: "error",
|
|
392
|
+
round: 0,
|
|
393
|
+
totalRounds: 0,
|
|
394
|
+
error: "Deliberation requires at least 2 active (non-muted) models.",
|
|
395
|
+
});
|
|
396
|
+
deliberatingRef.current = false;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Use config deliberation settings or auto-assign
|
|
400
|
+
const delibConfig = config.deliberation;
|
|
401
|
+
let roundConfigs;
|
|
402
|
+
if (delibConfig?.rounds && delibConfig.rounds.length > 0) {
|
|
403
|
+
roundConfigs = delibConfig.rounds
|
|
404
|
+
.filter((r) => activeModels.includes(r.model) && config.models[r.model])
|
|
405
|
+
.map((r) => ({
|
|
406
|
+
modelName: r.model,
|
|
407
|
+
role: r.role,
|
|
408
|
+
config: config.models[r.model],
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
roundConfigs = autoAssignRounds(activeModels, config.models);
|
|
413
|
+
}
|
|
414
|
+
if (roundConfigs.length < 2) {
|
|
415
|
+
setDeliberationProgress({
|
|
416
|
+
type: "error",
|
|
417
|
+
round: 0,
|
|
418
|
+
totalRounds: 0,
|
|
419
|
+
error: "Not enough configured models for deliberation (need ≥2).",
|
|
420
|
+
});
|
|
421
|
+
deliberatingRef.current = false;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Load constraint document if configured
|
|
425
|
+
let constraint;
|
|
426
|
+
if (delibConfig?.constraint_file) {
|
|
427
|
+
try {
|
|
428
|
+
constraint = await readFile(delibConfig.constraint_file, "utf-8");
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Constraint file not found — proceed without it
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
setDeliberationDocument("");
|
|
435
|
+
setDeliberationRounds([]);
|
|
436
|
+
setDeliberationScrollOffset(0);
|
|
437
|
+
let doc = "";
|
|
438
|
+
const stream = runDeliberation(session.teamMessages, roundConfigs, constraint);
|
|
439
|
+
for await (const event of stream) {
|
|
440
|
+
setDeliberationProgress(event);
|
|
441
|
+
if (event.type === "think_start") {
|
|
442
|
+
setDeliberationThinkText("");
|
|
443
|
+
setDeliberationDocument("");
|
|
444
|
+
}
|
|
445
|
+
else if (event.type === "think_text" && event.content) {
|
|
446
|
+
setDeliberationThinkText((prev) => prev + event.content);
|
|
447
|
+
}
|
|
448
|
+
else if (event.type === "think_end") {
|
|
449
|
+
// Think done — keep think text in state for UI reference,
|
|
450
|
+
// main round will populate deliberationDocument next.
|
|
451
|
+
}
|
|
452
|
+
else if (event.type === "round_start") {
|
|
453
|
+
doc = "";
|
|
454
|
+
setDeliberationDocument("");
|
|
455
|
+
setDeliberationThinkText("");
|
|
456
|
+
setDeliberationRounds((prev) => [
|
|
457
|
+
...prev,
|
|
458
|
+
{ round: event.round, modelName: event.modelName, role: event.role },
|
|
459
|
+
]);
|
|
460
|
+
}
|
|
461
|
+
else if (event.type === "text" && event.content) {
|
|
462
|
+
doc += event.content;
|
|
463
|
+
setDeliberationDocument(doc);
|
|
464
|
+
}
|
|
465
|
+
else if (event.type === "round_end") {
|
|
466
|
+
// Update the round entry with change metadata
|
|
467
|
+
setDeliberationRounds((prev) => prev.map((r) => r.round === event.round
|
|
468
|
+
? {
|
|
469
|
+
...r,
|
|
470
|
+
changeCount: event.changeCount,
|
|
471
|
+
changeSamples: event.changeSamples,
|
|
472
|
+
}
|
|
473
|
+
: r));
|
|
474
|
+
}
|
|
475
|
+
else if (event.type === "done") {
|
|
476
|
+
setDeliberationDocument(event.document ?? doc);
|
|
477
|
+
}
|
|
478
|
+
else if (event.type === "error") {
|
|
479
|
+
deliberatingRef.current = false;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
deliberatingRef.current = false;
|
|
484
|
+
}, [session.models, config]);
|
|
485
|
+
// ── Merge runner ──────────────────────────────────────────────
|
|
486
|
+
const runMergePipeline = useCallback(async () => {
|
|
487
|
+
if (deliberatingRef.current)
|
|
488
|
+
return;
|
|
489
|
+
deliberatingRef.current = true;
|
|
490
|
+
// Collect the last assistant response from each non-muted model
|
|
491
|
+
const outputs = [];
|
|
492
|
+
for (const m of session.models) {
|
|
493
|
+
if (m.muted)
|
|
494
|
+
continue;
|
|
495
|
+
// Find the last assistant message
|
|
496
|
+
const lastAssistant = [...m.messages].reverse().find((msg) => msg.role === "assistant");
|
|
497
|
+
if (lastAssistant?.content) {
|
|
498
|
+
outputs.push({ modelName: m.name, content: lastAssistant.content });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (outputs.length < 2) {
|
|
502
|
+
setDeliberationProgress({
|
|
503
|
+
type: "error",
|
|
504
|
+
round: 0,
|
|
505
|
+
totalRounds: 0,
|
|
506
|
+
error: "需要至少 2 个模型有回复才能合并。",
|
|
507
|
+
});
|
|
508
|
+
deliberatingRef.current = false;
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Find the last user message as the task
|
|
512
|
+
const firstModel = session.models.find((m) => !m.muted);
|
|
513
|
+
const lastUserMsg = firstModel?.messages
|
|
514
|
+
? [...firstModel.messages].reverse().find((m) => m.role === "user")
|
|
515
|
+
: null;
|
|
516
|
+
const task = lastUserMsg?.content ?? "合并以下模型输出";
|
|
517
|
+
// Use the first non-muted model as the merger
|
|
518
|
+
const mergerName = session.models.find((m) => !m.muted).name;
|
|
519
|
+
const mergerConfig = config.models[mergerName];
|
|
520
|
+
if (!mergerConfig) {
|
|
521
|
+
setDeliberationProgress({
|
|
522
|
+
type: "error",
|
|
523
|
+
round: 0,
|
|
524
|
+
totalRounds: 0,
|
|
525
|
+
error: `未找到模型 "${mergerName}" 的配置。`,
|
|
526
|
+
});
|
|
527
|
+
deliberatingRef.current = false;
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
setDeliberationDocument("");
|
|
531
|
+
setDeliberationRounds([]);
|
|
532
|
+
setDeliberationScrollOffset(0);
|
|
533
|
+
let doc = "";
|
|
534
|
+
const stream = runMerge(task, outputs, mergerConfig, mergerName);
|
|
535
|
+
for await (const event of stream) {
|
|
536
|
+
setDeliberationProgress(event);
|
|
537
|
+
if (event.type === "round_start") {
|
|
538
|
+
doc = "";
|
|
539
|
+
setDeliberationDocument("");
|
|
540
|
+
setDeliberationRounds([
|
|
541
|
+
{ round: 1, modelName: mergerName, role: "draft" },
|
|
542
|
+
]);
|
|
543
|
+
}
|
|
544
|
+
else if (event.type === "text" && event.content) {
|
|
545
|
+
doc += event.content;
|
|
546
|
+
setDeliberationDocument(doc);
|
|
547
|
+
}
|
|
548
|
+
else if (event.type === "done") {
|
|
549
|
+
setDeliberationDocument(event.document ?? doc);
|
|
550
|
+
}
|
|
551
|
+
else if (event.type === "error") {
|
|
552
|
+
deliberatingRef.current = false;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Inject the merged document into each model's history so they
|
|
557
|
+
// can discuss it when the user switches to chat mode.
|
|
558
|
+
if (doc) {
|
|
559
|
+
const contextMsg = `[合并结果]\n\n以下是将各模型输出合并后的最终文档。用户可以就此文档与你讨论。\n\n---\n${doc}\n---`;
|
|
560
|
+
for (const m of session.models) {
|
|
561
|
+
if (!m.muted) {
|
|
562
|
+
m.messages.push({ role: "user", content: contextMsg });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
deliberatingRef.current = false;
|
|
567
|
+
}, [session.models, config]);
|
|
257
568
|
const handleSubmit = useCallback(async (value) => {
|
|
258
569
|
const trimmed = value.trim();
|
|
259
570
|
if (!trimmed)
|
|
260
571
|
return;
|
|
572
|
+
// ── Team mode: submit runs deliberation or routes to model ──
|
|
573
|
+
if (teamMode) {
|
|
574
|
+
const r = reduceSubmitInTeam(currentModeState(), isOverview());
|
|
575
|
+
if (r.action === "block")
|
|
576
|
+
return;
|
|
577
|
+
inputHistoryRef.current.push(trimmed);
|
|
578
|
+
historyIdxRef.current = -1;
|
|
579
|
+
setInput("");
|
|
580
|
+
// All team interactions share session.teamMessages as context.
|
|
581
|
+
session.teamMessages.push({ role: "user", content: trimmed });
|
|
582
|
+
if (r.action === "deliberate") {
|
|
583
|
+
setDeliberationDocument("");
|
|
584
|
+
// Reset target to overview so OutputArea shows deliberation progress
|
|
585
|
+
session.setTarget({ type: "broadcast" });
|
|
586
|
+
setTargetVersion((v) => v + 1);
|
|
587
|
+
runDeliberationPipeline();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// r.action === "route_normally" — team directed chat.
|
|
591
|
+
// The user is drilling into a specific model. Use shared team context.
|
|
592
|
+
const targetModel = session.targetMode.type === "directed"
|
|
593
|
+
? session.targetMode.modelName
|
|
594
|
+
: null;
|
|
595
|
+
if (!targetModel)
|
|
596
|
+
return;
|
|
597
|
+
const tm = session.models.find((m) => m.name === targetModel && !m.muted);
|
|
598
|
+
if (!tm)
|
|
599
|
+
return;
|
|
600
|
+
const tmc = config.models[targetModel];
|
|
601
|
+
if (!tmc) {
|
|
602
|
+
tm.buffer = `[Error: No config for model "${targetModel}"]`;
|
|
603
|
+
setModelStates([...session.models]);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// Worktree setup
|
|
607
|
+
const taskId = Date.now().toString(36);
|
|
608
|
+
const wtManager = new WorktreeManager(process.cwd());
|
|
609
|
+
await wtManager.setup(taskId, [targetModel]);
|
|
610
|
+
const wtPath = wtManager.getWorktreePath(targetModel) ?? process.cwd();
|
|
611
|
+
tm.isStreaming = true;
|
|
612
|
+
tm.buffer = "";
|
|
613
|
+
setModelStates([...session.models]);
|
|
614
|
+
const stream = runTurn({
|
|
615
|
+
modelName: targetModel,
|
|
616
|
+
config: tmc,
|
|
617
|
+
messages: session.teamMessages,
|
|
618
|
+
systemPrompt: makeSystemPrompt(targetModel, tmc.provider),
|
|
619
|
+
tools: toolRegistry.getDefinitions(),
|
|
620
|
+
registry: toolRegistry,
|
|
621
|
+
permission: permissionManager,
|
|
622
|
+
worktreePath: wtPath,
|
|
623
|
+
});
|
|
624
|
+
for await (const event of stream) {
|
|
625
|
+
if (event.type === "text") {
|
|
626
|
+
tm.buffer += event.content;
|
|
627
|
+
}
|
|
628
|
+
else if (event.type === "done") {
|
|
629
|
+
tm.usage.input += event.usage.input;
|
|
630
|
+
tm.usage.output += event.usage.output;
|
|
631
|
+
tm.isStreaming = false;
|
|
632
|
+
}
|
|
633
|
+
else if (event.type === "error") {
|
|
634
|
+
tm.buffer += `\n[Error: ${event.message}]`;
|
|
635
|
+
tm.isStreaming = false;
|
|
636
|
+
}
|
|
637
|
+
setModelStates([...session.models]);
|
|
638
|
+
}
|
|
639
|
+
await wtManager.cleanup(taskId);
|
|
640
|
+
saveCurrentSession();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// ── Team mode toggle ────────────────────────────────────
|
|
644
|
+
if (trimmed === "/team" || trimmed === "/t") {
|
|
645
|
+
inputHistoryRef.current.push(trimmed);
|
|
646
|
+
historyIdxRef.current = -1;
|
|
647
|
+
setInput("");
|
|
648
|
+
setTeamMode((prev) => !prev);
|
|
649
|
+
// Entering a mode always lands on its overview
|
|
650
|
+
session.setTarget({ type: "broadcast" });
|
|
651
|
+
setTargetVersion((v) => v + 1);
|
|
652
|
+
setDeliberationProgress(null);
|
|
653
|
+
setDeliberationDocument("");
|
|
654
|
+
setDeliberationRounds([]);
|
|
655
|
+
setComparisonModel(null);
|
|
656
|
+
shortcutHandledRef.current = true;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
// ── Merge command: synthesize last outputs ───────────────
|
|
660
|
+
if (trimmed === "/merge" || trimmed === "/m") {
|
|
661
|
+
inputHistoryRef.current.push(trimmed);
|
|
662
|
+
historyIdxRef.current = -1;
|
|
663
|
+
setInput("");
|
|
664
|
+
setDeliberationDocument("");
|
|
665
|
+
runMergePipeline();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
261
668
|
// Add to input history
|
|
262
669
|
inputHistoryRef.current.push(trimmed);
|
|
263
670
|
historyIdxRef.current = -1;
|
|
@@ -322,7 +729,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
322
729
|
await worktreeManager.cleanup(taskId);
|
|
323
730
|
// ── Auto-save session ─────────────────────────────────────
|
|
324
731
|
saveCurrentSession();
|
|
325
|
-
}, [session, config, sessionId, saveCurrentSession]);
|
|
732
|
+
}, [session, config, sessionId, saveCurrentSession, teamMode, deliberationProgress, runDeliberationPipeline, setTargetVersion]);
|
|
326
733
|
const terminalWidth = process.stdout.columns ?? 80;
|
|
327
734
|
// ── No models configured: show startup guide ──────────────────
|
|
328
735
|
if (modelStates.length === 0) {
|
|
@@ -365,8 +772,7 @@ broadcast = true`;
|
|
|
365
772
|
configWarnings.length > 0 && (React.createElement(Box, { flexDirection: "column" }, configWarnings.map((w, i) => (React.createElement(Text, { key: i, color: "yellow" },
|
|
366
773
|
"\u26A0 ",
|
|
367
774
|
w.message))))),
|
|
368
|
-
React.createElement(
|
|
369
|
-
|
|
370
|
-
React.createElement(Text, null, "─".repeat(terminalWidth)),
|
|
775
|
+
React.createElement(Box, { flexGrow: 1 },
|
|
776
|
+
React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel, terminalWidth: terminalWidth, deliberationProgress: deliberationProgress, deliberationDocument: deliberationDocument, deliberationThinkText: deliberationThinkText, deliberationRounds: deliberationRounds, teamMode: teamMode, deliberationScrollOffset: deliberationScrollOffset })),
|
|
371
777
|
React.createElement(InputBar, { models: modelStates, activeModelName: activeModelName, prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
|
|
372
778
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import { formatTokens } from "./
|
|
3
|
+
import { formatTokens } from "./formatTokens.js";
|
|
4
4
|
const PANEL_LINES = 4;
|
|
5
5
|
export const BroadcastSummary = ({ models, terminalWidth }) => {
|
|
6
6
|
const activeModels = models.filter((m) => !m.muted);
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
thinkText?: string;
|
|
14
|
+
rounds: RoundSummary[];
|
|
15
|
+
scrollOffset?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const DeliberationView: React.FC<Props>;
|
|
18
|
+
export {};
|