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
@@ -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
+ };
@@ -1,5 +1,8 @@
1
1
  import React from "react";
2
+ import type { ModelState } from "../../core/types.js";
2
3
  interface Props {
4
+ models: ModelState[];
5
+ activeModelName: string | null;
3
6
  prefix: string;
4
7
  value: string;
5
8
  onChange: (value: string) => void;
@@ -1,11 +1,21 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import TextInput from "ink-text-input";
4
- export const InputBar = ({ prefix, value, onChange, onSubmit }) => (React.createElement(Box, { height: 1, flexDirection: "row" },
5
- React.createElement(Box, { marginRight: 1 },
6
- React.createElement(Text, { color: "green" },
7
- "[",
8
- prefix,
9
- "]")),
10
- React.createElement(Text, null, "> "),
11
- React.createElement(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit })));
4
+ export const InputBar = ({ models, activeModelName, prefix, value, onChange, onSubmit, }) => (React.createElement(Box, { flexDirection: "column" },
5
+ React.createElement(Box, { height: 1, flexDirection: "row" },
6
+ models.map((m) => {
7
+ const isTargeted = activeModelName === null || activeModelName === m.name;
8
+ return (React.createElement(Box, { key: m.name, marginRight: 1 },
9
+ React.createElement(Text, { color: isTargeted && !m.muted ? "green" : "gray", bold: isTargeted }, m.name),
10
+ isTargeted && !m.muted && React.createElement(Text, { color: "yellow" }, " \u25CF"),
11
+ m.muted && React.createElement(Text, { color: "gray" }, " [muted]")));
12
+ }),
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
+ React.createElement(Box, { height: 1, flexDirection: "row" },
15
+ React.createElement(Box, { marginRight: 1 },
16
+ React.createElement(Text, { color: "green" },
17
+ "[",
18
+ prefix,
19
+ "]")),
20
+ React.createElement(Text, null, "> "),
21
+ React.createElement(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit }))));
@@ -1,13 +1,25 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
+ import { formatTokens } from "./formatTokens.js";
3
4
  export const ModelDetail = ({ model, scrollOffset }) => {
4
- const allLines = model.buffer.split("\n");
5
+ const allLines = model.buffer ? model.buffer.split("\n") : [];
5
6
  const visibleLines = allLines.slice(scrollOffset);
6
- return (React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
7
- visibleLines.length === 0 && !model.isStreaming && (React.createElement(Text, { dimColor: true }, "No output yet")),
7
+ const totalTokens = model.usage.input + model.usage.output;
8
+ const isEmpty = allLines.length === 0 || (allLines.length === 1 && allLines[0].trim() === "");
9
+ return (React.createElement(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: "gray" },
10
+ React.createElement(Text, { bold: true }, model.name),
11
+ isEmpty && !model.isStreaming && (React.createElement(Text, { dimColor: true }, "No output")),
8
12
  visibleLines.map((line, i) => {
9
13
  const isError = /^\[?(?:Error|error)[:\]]/.test(line);
10
14
  return (React.createElement(Text, { key: scrollOffset + i, color: isError ? "red" : undefined }, line || " "));
11
15
  }),
12
- model.isStreaming && React.createElement(Text, { color: "gray" }, "\u258B")));
16
+ model.isStreaming && React.createElement(Text, { color: "gray" }, "\u258B"),
17
+ React.createElement(Text, { dimColor: true },
18
+ formatTokens(totalTokens),
19
+ "/",
20
+ formatTokens(model.contextLimit),
21
+ " \u00B7 ",
22
+ isEmpty ? 0 : allLines.length,
23
+ " lines \u00B7 ",
24
+ model.isStreaming ? "streaming..." : "done")));
13
25
  };
@@ -1,5 +1,7 @@
1
1
  import React from "react";
2
2
  import type { ModelState } from "../../core/types.js";
3
+ import type { DeliberationProgress } from "../../core/deliberation.js";
4
+ import { type RoundSummary } from "./DeliberationView.js";
3
5
  interface Props {
4
6
  models: ModelState[];
5
7
  targetMode: {
@@ -10,6 +12,12 @@ interface Props {
10
12
  };
11
13
  scrollOffsets: Record<string, number>;
12
14
  comparisonModel?: string | null;
15
+ terminalWidth: number;
16
+ deliberationProgress?: DeliberationProgress | null;
17
+ deliberationDocument?: string;
18
+ deliberationRounds?: RoundSummary[];
19
+ teamMode?: boolean;
20
+ deliberationScrollOffset?: number;
13
21
  }
14
22
  export declare const OutputArea: React.FC<Props>;
15
23
  export {};
@@ -2,9 +2,39 @@ import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import { BroadcastSummary } from "./BroadcastSummary.js";
4
4
  import { ModelDetail } from "./ModelDetail.js";
5
- export const OutputArea = ({ models, targetMode, scrollOffsets, comparisonModel, }) => {
5
+ import { DeliberationView } from "./DeliberationView.js";
6
+ export const OutputArea = ({ models, targetMode, scrollOffsets, comparisonModel, terminalWidth, deliberationProgress, deliberationDocument, deliberationRounds, teamMode, deliberationScrollOffset = 0, }) => {
7
+ // ── Team mode ──────────────────────────────────────────────────
8
+ if (teamMode) {
9
+ // During active deliberation (running): always show deliberation view
10
+ const isDeliberating = deliberationProgress &&
11
+ deliberationProgress.type !== "done" &&
12
+ deliberationProgress.type !== "error";
13
+ if (isDeliberating) {
14
+ return (React.createElement(DeliberationView, { progress: deliberationProgress, scrollOffset: deliberationScrollOffset, document: deliberationDocument ?? "", rounds: deliberationRounds ?? [] }));
15
+ }
16
+ // Team overview (broadcast target): show deliberation result or idle prompt
17
+ if (targetMode.type === "broadcast") {
18
+ if (deliberationProgress) {
19
+ return (React.createElement(DeliberationView, { progress: deliberationProgress, document: deliberationDocument ?? "", rounds: deliberationRounds ?? [], scrollOffset: deliberationScrollOffset }));
20
+ }
21
+ return (React.createElement(Box, { flexDirection: "column", flexGrow: 1, padding: 1 },
22
+ React.createElement(Text, { bold: true }, "\u56E2\u961F\u6A21\u5F0F"),
23
+ React.createElement(Text, null, " "),
24
+ React.createElement(Text, null, "\u8F93\u5165\u4EFB\u52A1\u63CF\u8FF0\u5373\u53EF\u542F\u52A8\u591A\u6A21\u578B\u63A5\u529B\u5BA1\u8BAE\u3002"),
25
+ React.createElement(Text, { dimColor: true }, "Tab \u53EF\u5207\u6362\u5230\u7279\u5B9A\u6A21\u578B\u79C1\u804A\u3002Shift+Tab \u8FD4\u56DE\u5E7F\u64AD\u6A21\u5F0F\u3002")));
26
+ }
27
+ // Team directed: Tab drilled into a specific model — show its detail
28
+ const teamModel = models.find((m) => m.name === targetMode.modelName);
29
+ if (teamModel) {
30
+ return (React.createElement(ModelDetail, { model: teamModel, scrollOffset: scrollOffsets[teamModel.name] ?? 0 }));
31
+ }
32
+ return (React.createElement(Box, { flexGrow: 1 },
33
+ React.createElement(Text, null, "No model selected")));
34
+ }
35
+ // ── Not team mode ──────────────────────────────────────────────
6
36
  if (targetMode.type === "broadcast") {
7
- return React.createElement(BroadcastSummary, { models: models });
37
+ return React.createElement(BroadcastSummary, { models: models, terminalWidth: terminalWidth });
8
38
  }
9
39
  const activeModel = models.find((m) => m.name === targetMode.modelName);
10
40
  if (!activeModel) {
@@ -19,10 +49,8 @@ export const OutputArea = ({ models, targetMode, scrollOffsets, comparisonModel,
19
49
  }
20
50
  return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 },
21
51
  React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginRight: 1 },
22
- React.createElement(Text, { bold: true }, activeModel.name),
23
52
  React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 })),
24
53
  React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
25
- React.createElement(Text, { bold: true }, compModel.name),
26
54
  React.createElement(ModelDetail, { model: compModel, scrollOffset: scrollOffsets[compModel.name] ?? 0 }))));
27
55
  }
28
56
  return React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 });
@@ -0,0 +1 @@
1
+ export declare function formatTokens(n: number): string;
@@ -0,0 +1,7 @@
1
+ export function formatTokens(n) {
2
+ if (n >= 1_000_000)
3
+ return `${(n / 1_000_000).toFixed(1)}M`;
4
+ if (n >= 1_000)
5
+ return `${Math.round(n / 1_000)}K`;
6
+ return String(n);
7
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Mode-transition decision functions for the App keyboard handler.
3
+ *
4
+ * Design: two top-level modes (Broadcast / Team), each with an overview
5
+ * and drill-down to individual model chats. Shift+Tab toggles modes;
6
+ * Tab cycles targets within the current mode; Esc returns to overview.
7
+ *
8
+ * Broadcast overview ←── Esc ── Broadcast directed (model N)
9
+ * ↑ ↓ Tab ↑ ↓ Tab
10
+ * Team overview ←── Esc ── Team directed (model N)
11
+ * ↑ ↓ Shift+Tab
12
+ */
13
+ export interface ModeState {
14
+ teamMode: boolean;
15
+ /** idle = no deliberation; running = rounds in progress; done = completed; error = aborted */
16
+ deliberationStatus: "idle" | "running" | "done" | "error";
17
+ comparisonModel: string | null;
18
+ comparisonFromBroadcast: boolean;
19
+ }
20
+ export interface TabResult {
21
+ /** Cycle the target within the current mode. */
22
+ cycleTarget: boolean;
23
+ clearComparison: boolean;
24
+ }
25
+ /**
26
+ * Tab cycles the target within the current mode (overview → model1 →
27
+ * model2 → … → overview). It never changes teamMode — Shift+Tab is
28
+ * the only way to toggle between broadcast and team.
29
+ */
30
+ export declare function reduceTab(_state: ModeState): TabResult;
31
+ export interface ShiftTabResult {
32
+ teamMode: boolean;
33
+ /** If true, the caller should set targetMode to broadcast (overview). */
34
+ goToOverview: boolean;
35
+ clearComparison: boolean;
36
+ /** If true, reset deliberation UI state (entering team mode fresh). */
37
+ resetDeliberation: boolean;
38
+ }
39
+ /** Toggle team/broadcast. Only works from overview. Entering a mode always lands on its overview. */
40
+ export declare function reduceShiftTab(state: ModeState, isOverview: boolean): ShiftTabResult | null;
41
+ export interface EscapeResult {
42
+ teamMode: boolean;
43
+ comparisonModel: string | null;
44
+ comparisonFromBroadcast: boolean;
45
+ /** Go to the current mode's overview (broadcast target). */
46
+ goToOverview: boolean;
47
+ abortDeliberation: boolean;
48
+ resetDeliberation: boolean;
49
+ restoreBroadcast: boolean;
50
+ }
51
+ /**
52
+ * Escape returns to the current mode's overview, or exits comparison /
53
+ * aborts a running deliberation. It never toggles teamMode.
54
+ */
55
+ export declare function reduceEscape(state: ModeState, isDeliberating: boolean): EscapeResult;
56
+ export interface KeyDResult {
57
+ comparisonModel: string | null;
58
+ comparisonFromBroadcast: boolean;
59
+ setDirectedTarget: string | null;
60
+ }
61
+ export declare function reduceKeyD(state: ModeState, unmutedNames: string[], currentDirectedTarget: string | null): KeyDResult;
62
+ /** Build a ModeState snapshot from UI state so the pure decision
63
+ * functions can drive the keyboard handler. */
64
+ export declare function buildModeState(params: {
65
+ teamMode: boolean;
66
+ deliberationProgress: {
67
+ type: string;
68
+ } | null;
69
+ comparisonModel: string | null;
70
+ comparisonFromBroadcast: boolean;
71
+ }): ModeState;
72
+ export type SubmitInTeamAction = "deliberate" | "route_normally" | "block";
73
+ /**
74
+ * In team overview: submit starts deliberation (idle or after a previous one).
75
+ * In team directed (Tab-ed to a model): submit routes as a directed message.
76
+ * Running deliberation: block.
77
+ */
78
+ export declare function reduceSubmitInTeam(state: ModeState, isOverview: boolean): {
79
+ action: SubmitInTeamAction;
80
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Mode-transition decision functions for the App keyboard handler.
3
+ *
4
+ * Design: two top-level modes (Broadcast / Team), each with an overview
5
+ * and drill-down to individual model chats. Shift+Tab toggles modes;
6
+ * Tab cycles targets within the current mode; Esc returns to overview.
7
+ *
8
+ * Broadcast overview ←── Esc ── Broadcast directed (model N)
9
+ * ↑ ↓ Tab ↑ ↓ Tab
10
+ * Team overview ←── Esc ── Team directed (model N)
11
+ * ↑ ↓ Shift+Tab
12
+ */
13
+ /**
14
+ * Tab cycles the target within the current mode (overview → model1 →
15
+ * model2 → … → overview). It never changes teamMode — Shift+Tab is
16
+ * the only way to toggle between broadcast and team.
17
+ */
18
+ export function reduceTab(_state) {
19
+ return { cycleTarget: true, clearComparison: true };
20
+ }
21
+ /** Toggle team/broadcast. Only works from overview. Entering a mode always lands on its overview. */
22
+ export function reduceShiftTab(state, isOverview) {
23
+ if (!isOverview)
24
+ return null;
25
+ const next = !state.teamMode;
26
+ return {
27
+ teamMode: next,
28
+ goToOverview: true,
29
+ clearComparison: true,
30
+ resetDeliberation: next, // entering team mode resets stale deliberation
31
+ };
32
+ }
33
+ /**
34
+ * Escape returns to the current mode's overview, or exits comparison /
35
+ * aborts a running deliberation. It never toggles teamMode.
36
+ */
37
+ export function reduceEscape(state, isDeliberating) {
38
+ // ── Team mode ──────────────────────────────────────────────
39
+ if (state.teamMode) {
40
+ if (state.deliberationStatus === "done") {
41
+ // Stay in team mode, go to overview (preserve result)
42
+ return {
43
+ teamMode: true,
44
+ comparisonModel: null,
45
+ comparisonFromBroadcast: false,
46
+ goToOverview: true,
47
+ abortDeliberation: false,
48
+ resetDeliberation: false,
49
+ restoreBroadcast: false,
50
+ };
51
+ }
52
+ if (state.deliberationStatus === "running") {
53
+ return {
54
+ teamMode: true,
55
+ comparisonModel: null,
56
+ comparisonFromBroadcast: false,
57
+ goToOverview: true,
58
+ abortDeliberation: isDeliberating,
59
+ resetDeliberation: true,
60
+ restoreBroadcast: false,
61
+ };
62
+ }
63
+ // Team idle — go to overview
64
+ return {
65
+ teamMode: true,
66
+ comparisonModel: null,
67
+ comparisonFromBroadcast: false,
68
+ goToOverview: true,
69
+ abortDeliberation: false,
70
+ resetDeliberation: false,
71
+ restoreBroadcast: false,
72
+ };
73
+ }
74
+ // ── Comparison mode ────────────────────────────────────────
75
+ if (state.comparisonModel !== null) {
76
+ return {
77
+ teamMode: false,
78
+ comparisonModel: null,
79
+ comparisonFromBroadcast: false,
80
+ goToOverview: true,
81
+ abortDeliberation: false,
82
+ resetDeliberation: false,
83
+ restoreBroadcast: state.comparisonFromBroadcast,
84
+ };
85
+ }
86
+ // ── Standalone deliberation ────────────────────────────────
87
+ if (state.deliberationStatus !== "idle") {
88
+ return {
89
+ teamMode: false,
90
+ comparisonModel: null,
91
+ comparisonFromBroadcast: false,
92
+ goToOverview: true,
93
+ abortDeliberation: isDeliberating,
94
+ resetDeliberation: true,
95
+ restoreBroadcast: false,
96
+ };
97
+ }
98
+ // ── Broadcast or directed (no comparison) ──────────────────
99
+ // Esc from directed → back to broadcast overview
100
+ return {
101
+ teamMode: false,
102
+ comparisonModel: null,
103
+ comparisonFromBroadcast: false,
104
+ goToOverview: true,
105
+ abortDeliberation: false,
106
+ resetDeliberation: false,
107
+ restoreBroadcast: false,
108
+ };
109
+ }
110
+ export function reduceKeyD(state, unmutedNames, currentDirectedTarget) {
111
+ // Exiting comparison mode
112
+ if (state.comparisonModel !== null) {
113
+ return { comparisonModel: null, comparisonFromBroadcast: false, setDirectedTarget: null };
114
+ }
115
+ // Need at least 2 unmuted models
116
+ if (unmutedNames.length < 2) {
117
+ return { comparisonModel: null, comparisonFromBroadcast: false, setDirectedTarget: null };
118
+ }
119
+ if (currentDirectedTarget === null) {
120
+ // From broadcast overview: target first, compare with second
121
+ return {
122
+ comparisonModel: unmutedNames[1],
123
+ comparisonFromBroadcast: true,
124
+ setDirectedTarget: unmutedNames[0],
125
+ };
126
+ }
127
+ // From directed mode: pick a different model to compare with
128
+ const idx = unmutedNames.indexOf(currentDirectedTarget);
129
+ if (idx === -1) {
130
+ return { comparisonModel: null, comparisonFromBroadcast: false, setDirectedTarget: null };
131
+ }
132
+ // Next model, wrapping around
133
+ const compareIdx = (idx + 1) % unmutedNames.length;
134
+ return {
135
+ comparisonModel: unmutedNames[compareIdx],
136
+ comparisonFromBroadcast: false,
137
+ setDirectedTarget: null,
138
+ };
139
+ }
140
+ // ── ModeState builder (extracted from app.tsx for testability) ──
141
+ /** Build a ModeState snapshot from UI state so the pure decision
142
+ * functions can drive the keyboard handler. */
143
+ export function buildModeState(params) {
144
+ let deliberationStatus = "idle";
145
+ if (params.deliberationProgress) {
146
+ const t = params.deliberationProgress.type;
147
+ if (t === "done")
148
+ deliberationStatus = "done";
149
+ else if (t === "error")
150
+ deliberationStatus = "error";
151
+ else
152
+ deliberationStatus = "running";
153
+ }
154
+ return {
155
+ teamMode: params.teamMode,
156
+ deliberationStatus,
157
+ comparisonModel: params.comparisonModel,
158
+ comparisonFromBroadcast: params.comparisonFromBroadcast,
159
+ };
160
+ }
161
+ /**
162
+ * In team overview: submit starts deliberation (idle or after a previous one).
163
+ * In team directed (Tab-ed to a model): submit routes as a directed message.
164
+ * Running deliberation: block.
165
+ */
166
+ export function reduceSubmitInTeam(state, isOverview) {
167
+ if (state.deliberationStatus === "running") {
168
+ return { action: "block" };
169
+ }
170
+ // Overview + idle/done → start (or restart) deliberation
171
+ if (isOverview && state.deliberationStatus !== "error") {
172
+ return { action: "deliberate" };
173
+ }
174
+ // Directed chat, or overview with error → normal routing
175
+ return { action: "route_normally" };
176
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "multiarena",
3
- "version": "0.1.0",
4
- "description": "Terminal-native multi-model AI coding assistant chat with multiple LLMs side by side",
3
+ "version": "0.1.3",
4
+ "description": "Terminal-native multi-model content generationN models collaborate to produce the best document, analysis, script, or strategy",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -11,25 +11,30 @@
11
11
  "multiarena": "dist/index.js"
12
12
  },
13
13
  "files": [
14
- "dist/"
14
+ "dist/",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
15
18
  ],
16
19
  "keywords": [
17
20
  "ai",
18
21
  "llm",
19
- "coding-assistant",
22
+ "content-generation",
23
+ "multi-model",
24
+ "collaboration",
25
+ "document-generation",
20
26
  "terminal",
21
27
  "cli",
22
28
  "claude",
23
29
  "gpt",
24
- "gemini",
25
- "ollama",
26
- "multi-model"
30
+ "gemini"
27
31
  ],
28
32
  "repository": {
29
33
  "type": "git",
30
- "url": "https://github.com/timgunnar/arena"
34
+ "url": "https://github.com/timgunnar/multiarena"
31
35
  },
32
36
  "scripts": {
37
+ "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
33
38
  "build": "tsc",
34
39
  "start": "node dist/index.js",
35
40
  "dev": "tsx src/index.ts",
@@ -1,9 +0,0 @@
1
- import React from "react";
2
- import type { ModelState } from "../../core/types.js";
3
- interface Props {
4
- models: ModelState[];
5
- activeModelName: string | null;
6
- contextUsages: Record<string, number>;
7
- }
8
- export declare const StatusBar: React.FC<Props>;
9
- export {};
@@ -1,51 +0,0 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
3
- function renderBar(ratio) {
4
- const blocks = 10;
5
- const filled = Math.round(ratio * blocks);
6
- return "█".repeat(filled) + "░".repeat(blocks - filled);
7
- }
8
- function formatTokens(n) {
9
- if (n >= 1_000_000)
10
- return `${(n / 1_000_000).toFixed(1)}M`;
11
- if (n >= 1_000)
12
- return `${Math.round(n / 1_000)}K`;
13
- return String(n);
14
- }
15
- function barColor(ratio) {
16
- if (ratio > 0.9)
17
- return "red";
18
- if (ratio > 0.7)
19
- return "yellow";
20
- return "green";
21
- }
22
- export const StatusBar = ({ models, activeModelName, contextUsages }) => (React.createElement(Box, { flexDirection: "column" },
23
- React.createElement(Box, { height: 1, flexDirection: "row" }, models.map((m) => {
24
- const isActive = activeModelName === m.name;
25
- const hasNew = m.buffer.length > 0 && !isActive;
26
- const color = isActive ? "green" : "white";
27
- return (React.createElement(Box, { key: m.name, marginRight: 1 },
28
- React.createElement(Text, { color: color, bold: isActive }, m.name),
29
- hasNew && React.createElement(Text, { color: "yellow" }, " \u25CF"),
30
- m.muted && React.createElement(Text, { color: "gray" }, " [muted]")));
31
- })),
32
- React.createElement(Box, { height: 1, flexDirection: "row" }, models.map((m) => {
33
- const usage = contextUsages[m.name] ?? 0;
34
- const pct = Math.round(usage * 100);
35
- const color = barColor(usage);
36
- const used = m.usage.input + m.usage.output;
37
- return (React.createElement(Box, { key: m.name, marginRight: 1 },
38
- React.createElement(Text, { dimColor: true },
39
- formatTokens(used),
40
- "/",
41
- formatTokens(m.contextLimit),
42
- " "),
43
- React.createElement(Text, { color: color },
44
- renderBar(usage),
45
- " ",
46
- pct,
47
- "%"),
48
- usage > 0.9 && React.createElement(Text, { color: "red" }, " \u26A0")));
49
- })),
50
- React.createElement(Box, { height: 1, flexDirection: "row" },
51
- React.createElement(Text, { dimColor: true }, "Tab:switch d:compare m:mute r:reset q:quit \u2191\u2193:scroll/history Esc:cancel"))));