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.
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/cli/args.d.ts +11 -0
- package/dist/cli/args.js +56 -0
- package/dist/config/loader.js +2 -2
- package/dist/config/types.d.ts +11 -1
- package/dist/core/deliberation.d.ts +53 -0
- package/dist/core/deliberation.js +356 -0
- package/dist/core/session.d.ts +3 -1
- package/dist/core/session.js +20 -17
- package/dist/core/turn.d.ts +2 -0
- package/dist/core/turn.js +32 -5
- package/dist/index.js +3 -49
- package/dist/isolation/worktree.d.ts +1 -1
- package/dist/isolation/worktree.js +8 -8
- package/dist/persistence/session.js +1 -1
- package/dist/provider/adapters/openai.d.ts +15 -0
- package/dist/provider/adapters/openai.js +67 -8
- package/dist/provider/provider.js +4 -0
- package/dist/tools/builtin/bash.js +6 -1
- package/dist/ui/app.js +426 -46
- package/dist/ui/components/BroadcastSummary.d.ts +1 -0
- package/dist/ui/components/BroadcastSummary.js +24 -8
- package/dist/ui/components/DeliberationView.d.ts +17 -0
- package/dist/ui/components/DeliberationView.js +81 -0
- package/dist/ui/components/InputBar.d.ts +3 -0
- package/dist/ui/components/InputBar.js +18 -8
- package/dist/ui/components/ModelDetail.js +16 -4
- package/dist/ui/components/OutputArea.d.ts +8 -0
- package/dist/ui/components/OutputArea.js +32 -4
- package/dist/ui/components/formatTokens.d.ts +1 -0
- package/dist/ui/components/formatTokens.js +7 -0
- package/dist/ui/modeTransitions.d.ts +80 -0
- package/dist/ui/modeTransitions.js +176 -0
- package/package.json +13 -8
- package/dist/ui/components/StatusBar.d.ts +0 -9
- 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,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, {
|
|
5
|
-
React.createElement(Box, {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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,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.
|
|
4
|
-
"description": "Terminal-native multi-model
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Terminal-native multi-model content generation — N 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
|
-
"
|
|
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/
|
|
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"))));
|