multiarena 0.1.1 → 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/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,28 @@ 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 [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();
55
73
  const activeScrollModel = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
56
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
+ }
57
80
  const modelName = activeScrollModel;
58
81
  if (!modelName)
59
82
  return;
@@ -61,7 +84,7 @@ export const App = ({ sessionId: initialSessionId }) => {
61
84
  ...prev,
62
85
  [modelName]: Math.max(0, (prev[modelName] ?? 0) + delta),
63
86
  }));
64
- }, [activeScrollModel]);
87
+ }, [activeScrollModel, teamMode, deliberationProgress]);
65
88
  // Input history
66
89
  const inputHistoryRef = useRef([]);
67
90
  const historyIdxRef = useRef(-1);
@@ -95,9 +118,13 @@ export const App = ({ sessionId: initialSessionId }) => {
95
118
  lastTarget,
96
119
  });
97
120
  }, [session, sessionId]);
98
- const targetPrefix = session.targetMode.type === "broadcast"
99
- ? "all"
100
- : 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;
101
128
  const activeModelName = session.targetMode.type === "broadcast" ? null : session.targetMode.modelName;
102
129
  // Save session on process exit (Ctrl+C, kill, etc.)
103
130
  useEffect(() => {
@@ -119,6 +146,63 @@ export const App = ({ sessionId: initialSessionId }) => {
119
146
  const wm = new WorktreeManager(process.cwd());
120
147
  wm.sweepOrphans().catch(() => { });
121
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
+ }, []);
122
206
  // Clear the input bar whenever a shortcut was handled (runs after the render
123
207
  // batch so it overrides any concurrent setInput from ink-text-input).
124
208
  useEffect(() => {
@@ -127,29 +211,83 @@ export const App = ({ sessionId: initialSessionId }) => {
127
211
  setInput("");
128
212
  }
129
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
+ }
130
228
  // 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
229
  useInput((inputValue, key) => {
134
- if (key.tab) {
135
- 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);
136
250
  setComparisonModel(null);
137
- comparisonFromBroadcastRef.current = false;
138
- 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
+ }
139
260
  return;
140
261
  }
141
- // Escape dismisses comparison mode (restore broadcast if entered from there)
262
+ // ── Escape: return to current mode's overview ────────────────
142
263
  if (key.escape) {
143
- if (comparisonModel) {
144
- if (comparisonFromBroadcastRef.current) {
145
- session.setTarget({ type: "broadcast" });
146
- comparisonFromBroadcastRef.current = false;
147
- }
148
- setComparisonModel(null);
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);
282
+ }
283
+ if (r.restoreBroadcast) {
284
+ // Exiting comparison that entered from broadcast — restore broadcast
285
+ session.setTarget({ type: "broadcast" });
286
+ comparisonFromBroadcastRef.current = false;
149
287
  setModelStates([...session.models]);
150
- shortcutHandledRef.current = true;
151
- return;
152
288
  }
289
+ shortcutHandledRef.current = true;
290
+ return;
153
291
  }
154
292
  if (key.upArrow) {
155
293
  if (input.length === 0) {
@@ -193,37 +331,21 @@ export const App = ({ sessionId: initialSessionId }) => {
193
331
  return;
194
332
  // 'd' — toggle comparison mode (current model vs next unmuted model)
195
333
  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);
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);
203
342
  }
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
- }
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);
225
348
  }
226
- setModelStates([...session.models]);
227
349
  shortcutHandledRef.current = true;
228
350
  return;
229
351
  }
@@ -254,10 +376,240 @@ export const App = ({ sessionId: initialSessionId }) => {
254
376
  return;
255
377
  }
256
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]);
257
566
  const handleSubmit = useCallback(async (value) => {
258
567
  const trimmed = value.trim();
259
568
  if (!trimmed)
260
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
+ }
261
613
  // Add to input history
262
614
  inputHistoryRef.current.push(trimmed);
263
615
  historyIdxRef.current = -1;
@@ -322,7 +674,7 @@ export const App = ({ sessionId: initialSessionId }) => {
322
674
  await worktreeManager.cleanup(taskId);
323
675
  // ── Auto-save session ─────────────────────────────────────
324
676
  saveCurrentSession();
325
- }, [session, config, sessionId, saveCurrentSession]);
677
+ }, [session, config, sessionId, saveCurrentSession, teamMode, deliberationProgress, runDeliberationPipeline, setTargetVersion]);
326
678
  const terminalWidth = process.stdout.columns ?? 80;
327
679
  // ── No models configured: show startup guide ──────────────────
328
680
  if (modelStates.length === 0) {
@@ -365,8 +717,7 @@ broadcast = true`;
365
717
  configWarnings.length > 0 && (React.createElement(Box, { flexDirection: "column" }, configWarnings.map((w, i) => (React.createElement(Text, { key: i, color: "yellow" },
366
718
  "\u26A0 ",
367
719
  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)),
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 })),
371
722
  React.createElement(InputBar, { models: modelStates, activeModelName: activeModelName, prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
372
723
  };
@@ -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,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 {};
@@ -0,0 +1,81 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { roundLabel } from "../../core/deliberation.js";
4
+ const ROLE_COLORS = {
5
+ draft: "cyan",
6
+ revise: "yellow",
7
+ polish: "green",
8
+ review: "magenta",
9
+ };
10
+ function spinner(frame) {
11
+ const chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ return chars[frame % chars.length] ?? ".";
13
+ }
14
+ export const DeliberationView = ({ progress, document, rounds, scrollOffset = 0 }) => {
15
+ const role = progress.role ?? "draft";
16
+ const roleColor = ROLE_COLORS[role];
17
+ const isActive = progress.type !== "done" && progress.type !== "error";
18
+ const isDone = progress.type === "done";
19
+ const spin = spinner(Date.now() % 10);
20
+ const allLines = document ? document.split("\n") : [];
21
+ const lines = allLines.slice(scrollOffset);
22
+ // Count total changes across all rounds
23
+ const totalChanges = rounds.reduce((sum, r) => sum + (r.changeCount ?? 0), 0);
24
+ return (React.createElement(Box, { flexDirection: "column", flexGrow: 1, padding: 1 },
25
+ React.createElement(Box, { flexDirection: "row" },
26
+ React.createElement(Text, { bold: true }, isDone
27
+ ? `审议完成 · ${rounds.length} 轮 · ${totalChanges} 处修改`
28
+ : isActive
29
+ ? `第 ${progress.round}/${progress.totalRounds} 轮:${progress.modelName ?? "?"} (${roundLabel(role)})`
30
+ : "审议出错")),
31
+ React.createElement(Box, { flexDirection: "row", marginY: 1 }, rounds.map((r, i) => {
32
+ const isPast = isDone || r.round < progress.round;
33
+ const isCurrent = !isDone && r.round === progress.round;
34
+ return (React.createElement(Box, { key: i, marginRight: 2, flexDirection: "column" },
35
+ React.createElement(Box, { flexDirection: "row" },
36
+ React.createElement(Text, { color: isPast ? "green" : isCurrent ? roleColor : "gray" },
37
+ isPast ? "✓" : isCurrent ? spin : "○",
38
+ " ",
39
+ roundLabel(r.role))),
40
+ React.createElement(Box, { flexDirection: "row" },
41
+ React.createElement(Text, { dimColor: true }, r.modelName))));
42
+ })),
43
+ isActive && (React.createElement(Box, { marginBottom: 1 },
44
+ React.createElement(Text, { color: roleColor },
45
+ roundLabel(role),
46
+ "\u4E2D \u2014 ",
47
+ progress.modelName,
48
+ " \u2014 \u6B63\u5728\u751F\u6210\u2026"))),
49
+ progress.type === "error" && (React.createElement(Box, { marginBottom: 1 },
50
+ React.createElement(Text, { color: "red" },
51
+ "\u9519\u8BEF\uFF1A",
52
+ progress.error))),
53
+ isDone && rounds.length > 0 && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
54
+ React.createElement(Text, { bold: true }, "\u2500\u2500 \u5BA1\u8BAE\u8FC7\u7A0B \u2500\u2500"),
55
+ rounds.map((r) => {
56
+ const hasChanges = (r.changeCount ?? 0) > 0;
57
+ return (React.createElement(Box, { key: r.round, flexDirection: "column", marginTop: 1 },
58
+ React.createElement(Text, null,
59
+ React.createElement(Text, { color: "cyan" },
60
+ r.round,
61
+ "."),
62
+ " ",
63
+ React.createElement(Text, { bold: true }, r.modelName),
64
+ React.createElement(Text, { color: "gray" },
65
+ "\uFF08",
66
+ roundLabel(r.role),
67
+ "\uFF09"),
68
+ hasChanges && (React.createElement(Text, { color: "yellow" },
69
+ " \u2014 ",
70
+ r.changeCount,
71
+ " \u5904\u4FEE\u6539")),
72
+ !hasChanges && r.role !== "draft" && (React.createElement(Text, { color: "gray" }, " \u2014 \u65E0\u4FEE\u6539"))),
73
+ r.changeSamples && r.changeSamples.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, r.changeSamples.map((s, j) => (React.createElement(Text, { key: j, dimColor: true }, ` ${s}`)))))));
74
+ }),
75
+ React.createElement(Text, null, " "))),
76
+ isDone && (React.createElement(Box, { marginBottom: 1 },
77
+ React.createElement(Text, { bold: true, color: "green" }, "\u2500\u2500 \u6700\u7EC8\u6587\u6863 \u2500\u2500"))),
78
+ React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
79
+ lines.length === 0 && isActive && (React.createElement(Text, { dimColor: true }, "\u7B49\u5F85\u8F93\u51FA\u2026")),
80
+ lines.map((line, i) => (React.createElement(Text, { key: i }, line))))));
81
+ };
@@ -10,7 +10,7 @@ export const InputBar = ({ models, activeModelName, prefix, value, onChange, onS
10
10
  isTargeted && !m.muted && React.createElement(Text, { color: "yellow" }, " \u25CF"),
11
11
  m.muted && React.createElement(Text, { color: "gray" }, " [muted]")));
12
12
  }),
13
- React.createElement(Text, { dimColor: true }, " \u2014 Tab:switch d:compare m:mute r:reset q:quit \u2191\u2193:scroll/history Esc:cancel")),
13
+ React.createElement(Text, { dimColor: true }, " \u2014 Shift+Tab:/team Tab:model d:compare m:mute r:reset q:quit \u2191\u2193:scroll/history Esc:cancel")),
14
14
  React.createElement(Box, { height: 1, flexDirection: "row" },
15
15
  React.createElement(Box, { marginRight: 1 },
16
16
  React.createElement(Text, { color: "green" },