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.
Files changed (38) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +282 -0
  4. package/dist/cli/args.d.ts +11 -0
  5. package/dist/cli/args.js +56 -0
  6. package/dist/config/loader.js +2 -2
  7. package/dist/config/types.d.ts +11 -1
  8. package/dist/core/deliberation.d.ts +53 -0
  9. package/dist/core/deliberation.js +356 -0
  10. package/dist/core/session.d.ts +3 -1
  11. package/dist/core/session.js +20 -17
  12. package/dist/core/turn.d.ts +2 -0
  13. package/dist/core/turn.js +32 -5
  14. package/dist/index.js +3 -49
  15. package/dist/isolation/worktree.d.ts +1 -1
  16. package/dist/isolation/worktree.js +8 -8
  17. package/dist/persistence/session.js +1 -1
  18. package/dist/provider/adapters/openai.d.ts +15 -0
  19. package/dist/provider/adapters/openai.js +67 -8
  20. package/dist/provider/provider.js +4 -0
  21. package/dist/tools/builtin/bash.js +6 -1
  22. package/dist/ui/app.js +426 -46
  23. package/dist/ui/components/BroadcastSummary.d.ts +1 -0
  24. package/dist/ui/components/BroadcastSummary.js +24 -8
  25. package/dist/ui/components/DeliberationView.d.ts +17 -0
  26. package/dist/ui/components/DeliberationView.js +81 -0
  27. package/dist/ui/components/InputBar.d.ts +3 -0
  28. package/dist/ui/components/InputBar.js +18 -8
  29. package/dist/ui/components/ModelDetail.js +16 -4
  30. package/dist/ui/components/OutputArea.d.ts +8 -0
  31. package/dist/ui/components/OutputArea.js +32 -4
  32. package/dist/ui/components/formatTokens.d.ts +1 -0
  33. package/dist/ui/components/formatTokens.js +7 -0
  34. package/dist/ui/modeTransitions.d.ts +80 -0
  35. package/dist/ui/modeTransitions.js +176 -0
  36. package/package.json +13 -8
  37. package/dist/ui/components/StatusBar.d.ts +0 -9
  38. 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 { StatusBar } from "./components/StatusBar.js";
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
- const SYSTEM_PROMPT = "You are a helpful AI coding assistant. Be concise.";
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: 128000,
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 = session.targetMode.type === "broadcast"
97
- ? "all"
98
- : session.targetMode.modelName;
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
- if (key.tab) {
133
- session.cycleTarget();
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
- setModelStates([...session.models]);
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 dismisses comparison mode
262
+ // ── Escape: return to current mode's overview ────────────────
139
263
  if (key.escape) {
140
- if (comparisonModel) {
141
- setComparisonModel(null);
142
- shortcutHandledRef.current = true;
143
- return;
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
- if (comparisonModel) {
189
- setComparisonModel(null);
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
- else {
192
- const unmuted = session.models.filter((m) => !m.muted);
193
- const baseName = session.targetMode.type === "directed"
194
- ? session.targetMode.modelName
195
- : unmuted[0]?.name;
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: SYSTEM_PROMPT,
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" }, "Arena \u2014 Multi-Model AI Coding Assistant"),
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" }, ".arenarc"),
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(Text, null, "─".repeat(terminalWidth)),
340
- React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel }),
341
- React.createElement(Text, null, "─".repeat(terminalWidth)),
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
  };
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import type { ModelState } from "../../core/types.js";
3
3
  interface Props {
4
4
  models: ModelState[];
5
+ terminalWidth: number;
5
6
  }
6
7
  export declare const BroadcastSummary: React.FC<Props>;
7
8
  export {};
@@ -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
- return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 }, activeModels.map((m) => {
7
- const lines = m.buffer.split("\n").slice(0, PANEL_LINES);
8
- const totalLines = m.buffer.split("\n").length;
9
- return (React.createElement(Box, { key: m.name, flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: "gray", marginRight: 1 },
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
- lines.map((line, i) => (React.createElement(Text, { key: i, wrap: "truncate" }, line || " "))),
12
- totalLines === 0 && (React.createElement(Text, { dimColor: true }, "Waiting...")),
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 {};