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/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 assistant. You are the "${modelName}" model (provider: ${provider}). Be concise.`;
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 = session.targetMode.type === "broadcast"
99
- ? "all"
100
- : session.targetMode.modelName;
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
- if (key.tab) {
135
- session.cycleTarget();
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
- comparisonFromBroadcastRef.current = false;
138
- setModelStates([...session.models]);
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 dismisses comparison mode (restore broadcast if entered from there)
263
+ // ── Escape: return to current mode's overview ────────────────
142
264
  if (key.escape) {
143
- if (comparisonModel) {
144
- if (comparisonFromBroadcastRef.current) {
145
- session.setTarget({ type: "broadcast" });
146
- comparisonFromBroadcastRef.current = false;
147
- }
148
- setComparisonModel(null);
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
- if (comparisonModel) {
197
- // Exiting comparison: restore broadcast if we entered from there
198
- if (comparisonFromBroadcastRef.current) {
199
- session.setTarget({ type: "broadcast" });
200
- comparisonFromBroadcastRef.current = false;
201
- }
202
- setComparisonModel(null);
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
- else {
205
- const unmuted = session.models.filter((m) => !m.muted);
206
- if (unmuted.length < 2) {
207
- shortcutHandledRef.current = true;
208
- return;
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(Text, null, "─".repeat(terminalWidth)),
369
- React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel, terminalWidth: terminalWidth }),
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 "./StatusBar.js";
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 {};