multiarena 0.1.0 → 0.1.1
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/config/loader.js +2 -2
- package/dist/config/types.d.ts +1 -1
- package/dist/core/session.d.ts +2 -0
- package/dist/core/session.js +15 -13
- package/dist/core/turn.d.ts +2 -0
- package/dist/core/turn.js +32 -5
- package/dist/index.js +3 -3
- 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.js +53 -8
- package/dist/provider/provider.js +4 -0
- package/dist/tools/builtin/bash.js +6 -1
- package/dist/ui/app.js +49 -20
- package/dist/ui/components/BroadcastSummary.d.ts +1 -0
- package/dist/ui/components/BroadcastSummary.js +24 -8
- 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 +1 -0
- package/dist/ui/components/OutputArea.js +2 -4
- package/dist/ui/components/StatusBar.d.ts +1 -0
- package/dist/ui/components/StatusBar.js +5 -6
- package/package.json +1 -1
package/dist/config/loader.js
CHANGED
|
@@ -48,8 +48,8 @@ export function validateConfig(config) {
|
|
|
48
48
|
}
|
|
49
49
|
export function loadConfig() {
|
|
50
50
|
const candidates = [
|
|
51
|
-
path.join(process.cwd(), ".
|
|
52
|
-
path.join(os.homedir(), ".
|
|
51
|
+
path.join(process.cwd(), ".multiarenarc"),
|
|
52
|
+
path.join(os.homedir(), ".multiarenarc"),
|
|
53
53
|
];
|
|
54
54
|
let resolved = {};
|
|
55
55
|
for (const p of candidates) {
|
package/dist/config/types.d.ts
CHANGED
package/dist/core/session.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export declare class Session {
|
|
|
28
28
|
addAssistantMessage(modelName: string, content: string): void;
|
|
29
29
|
/** Append tool result to a model's history */
|
|
30
30
|
addToolResult(modelName: string, toolCallId: string, result: string): void;
|
|
31
|
+
setTarget(target: TargetMode): void;
|
|
31
32
|
/** Cycle Tab through targets: broadcast → model1 → model2 → ... → broadcast */
|
|
32
33
|
cycleTarget(): TargetMode;
|
|
33
34
|
jumpToModel(modelName: string): void;
|
|
@@ -38,3 +39,4 @@ export declare class Session {
|
|
|
38
39
|
toJSON(): SessionSnapshot;
|
|
39
40
|
private findModel;
|
|
40
41
|
}
|
|
42
|
+
export declare function contextLimitForModel(provider: string, explicit?: number): number;
|
package/dist/core/session.js
CHANGED
|
@@ -23,7 +23,7 @@ export class Session {
|
|
|
23
23
|
buffer: "",
|
|
24
24
|
isStreaming: false,
|
|
25
25
|
usage: { input: 0, output: 0 },
|
|
26
|
-
contextLimit: contextLimitForModel(mc?.
|
|
26
|
+
contextLimit: contextLimitForModel(mc?.provider ?? "", mc?.context_limit),
|
|
27
27
|
};
|
|
28
28
|
});
|
|
29
29
|
this.state = {
|
|
@@ -71,6 +71,9 @@ export class Session {
|
|
|
71
71
|
m.messages.push({ role: "tool", content: result, tool_call_id: toolCallId });
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
setTarget(target) {
|
|
75
|
+
this.state.targetMode = target;
|
|
76
|
+
}
|
|
74
77
|
/** Cycle Tab through targets: broadcast → model1 → model2 → ... → broadcast */
|
|
75
78
|
cycleTarget() {
|
|
76
79
|
const current = this.state.targetMode;
|
|
@@ -138,18 +141,17 @@ export class Session {
|
|
|
138
141
|
return this.state.models.find((m) => m.name === name);
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
|
-
function contextLimitForModel(
|
|
144
|
+
export function contextLimitForModel(provider, explicit) {
|
|
142
145
|
if (explicit && explicit > 0)
|
|
143
146
|
return explicit;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return 128000;
|
|
148
|
-
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return 128000;
|
|
147
|
+
// Sensible defaults per provider. Users can override with context_limit in config.
|
|
148
|
+
switch (provider) {
|
|
149
|
+
case "anthropic": return 200000;
|
|
150
|
+
case "openai": return 128000;
|
|
151
|
+
case "google": return 1048576;
|
|
152
|
+
case "deepseek": return 1048576;
|
|
153
|
+
case "minimax": return 1048576;
|
|
154
|
+
case "ollama": return 128000;
|
|
155
|
+
default: return 128000;
|
|
156
|
+
}
|
|
155
157
|
}
|
package/dist/core/turn.d.ts
CHANGED
|
@@ -29,3 +29,5 @@ export interface TurnResult {
|
|
|
29
29
|
* have been pushed into ctx.messages — the caller only needs to persist.
|
|
30
30
|
*/
|
|
31
31
|
export declare function runTurn(ctx: TurnContext): AsyncGenerator<StreamEvent>;
|
|
32
|
+
/** Turn a tool name + args into a human-readable action label. */
|
|
33
|
+
export declare function friendlyToolLabel(name: string, args: Record<string, unknown>): string;
|
package/dist/core/turn.js
CHANGED
|
@@ -65,15 +65,19 @@ export async function* runTurn(ctx) {
|
|
|
65
65
|
});
|
|
66
66
|
// Execute tools and feed results back
|
|
67
67
|
for (const tc of pendingToolCalls) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
if (!tc.name || tc.name.trim() === "") {
|
|
69
|
+
const errMsg = "Tool call with empty name — skipped";
|
|
70
|
+
ctx.messages.push({ role: "tool", content: errMsg, tool_call_id: tc.id });
|
|
71
|
+
allText.push(errMsg + "\n");
|
|
72
|
+
yield { type: "text", content: errMsg + "\n" };
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
71
75
|
let args;
|
|
72
76
|
try {
|
|
73
|
-
args = JSON.parse(tc.arguments);
|
|
77
|
+
args = JSON.parse(tc.arguments || "{}");
|
|
74
78
|
}
|
|
75
79
|
catch {
|
|
76
|
-
const errMsg = `Failed to parse tool arguments: ${tc.arguments}`;
|
|
80
|
+
const errMsg = `Failed to parse tool arguments: ${tc.arguments || "(empty)"}`;
|
|
77
81
|
ctx.messages.push({ role: "tool", content: errMsg, tool_call_id: tc.id });
|
|
78
82
|
allText.push(errMsg + "\n");
|
|
79
83
|
yield { type: "text", content: errMsg + "\n" };
|
|
@@ -87,6 +91,10 @@ export async function* runTurn(ctx) {
|
|
|
87
91
|
yield { type: "text", content: errMsg + "\n" };
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
94
|
+
// Friendly label — shows what the model is doing in plain language
|
|
95
|
+
const label = friendlyToolLabel(tc.name, args);
|
|
96
|
+
allText.push(label);
|
|
97
|
+
yield { type: "text", content: label };
|
|
90
98
|
let result;
|
|
91
99
|
try {
|
|
92
100
|
result = await ctx.registry.execute(tc.name, args, ctx.worktreePath);
|
|
@@ -110,3 +118,22 @@ export async function* runTurn(ctx) {
|
|
|
110
118
|
provider?.abort();
|
|
111
119
|
}
|
|
112
120
|
}
|
|
121
|
+
/** Turn a tool name + args into a human-readable action label. */
|
|
122
|
+
export function friendlyToolLabel(name, args) {
|
|
123
|
+
switch (name) {
|
|
124
|
+
case "bash":
|
|
125
|
+
return `\n$ ${args.command ?? "(no command)"}\n`;
|
|
126
|
+
case "read_file":
|
|
127
|
+
return `\nReading ${args.file_path ?? "(?)"}\n`;
|
|
128
|
+
case "write_file":
|
|
129
|
+
return `\nWriting ${args.file_path ?? "(?)"}\n`;
|
|
130
|
+
case "edit_file":
|
|
131
|
+
return `\nEditing ${args.file_path ?? "(?)"}\n`;
|
|
132
|
+
case "glob":
|
|
133
|
+
return `\nFinding ${args.pattern ?? "(?)"}\n`;
|
|
134
|
+
case "grep":
|
|
135
|
+
return `\nSearching "${args.pattern ?? "(?)"}"\n`;
|
|
136
|
+
default:
|
|
137
|
+
return `\nRunning ${name}...\n`;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -17,10 +17,10 @@ const PKG_VERSION = (() => {
|
|
|
17
17
|
return "0.1.0";
|
|
18
18
|
}
|
|
19
19
|
})();
|
|
20
|
-
const HELP = `
|
|
20
|
+
const HELP = `multiarena — Multi-Model AI Coding Assistant
|
|
21
21
|
|
|
22
22
|
Usage:
|
|
23
|
-
|
|
23
|
+
multiarena [options]
|
|
24
24
|
|
|
25
25
|
Options:
|
|
26
26
|
--new Start a new session (default)
|
|
@@ -56,7 +56,7 @@ if (showHelp) {
|
|
|
56
56
|
process.exit(0);
|
|
57
57
|
}
|
|
58
58
|
if (showVersion) {
|
|
59
|
-
console.log(`
|
|
59
|
+
console.log(`multiarena v${PKG_VERSION}`);
|
|
60
60
|
process.exit(0);
|
|
61
61
|
}
|
|
62
62
|
if (listOnly) {
|
|
@@ -2,7 +2,7 @@ export declare class WorktreeManager {
|
|
|
2
2
|
private git;
|
|
3
3
|
private worktrees;
|
|
4
4
|
constructor(repoPath: string);
|
|
5
|
-
/** Clean up orphaned
|
|
5
|
+
/** Clean up orphaned multiarena branches and worktree directories from prior crashes. */
|
|
6
6
|
sweepOrphans(): Promise<number>;
|
|
7
7
|
setup(taskId: string, modelNames: string[]): Promise<Map<string, string>>;
|
|
8
8
|
getWorktreePath(modelName: string): string | undefined;
|
|
@@ -8,7 +8,7 @@ export class WorktreeManager {
|
|
|
8
8
|
constructor(repoPath) {
|
|
9
9
|
this.git = simpleGit(repoPath);
|
|
10
10
|
}
|
|
11
|
-
/** Clean up orphaned
|
|
11
|
+
/** Clean up orphaned multiarena branches and worktree directories from prior crashes. */
|
|
12
12
|
async sweepOrphans() {
|
|
13
13
|
let cleaned = 0;
|
|
14
14
|
// Parse registered worktrees from `git worktree list --porcelain`
|
|
@@ -23,7 +23,7 @@ export class WorktreeManager {
|
|
|
23
23
|
registeredPaths.add(currentPath);
|
|
24
24
|
}
|
|
25
25
|
else if (line.startsWith("branch ") && currentPath) {
|
|
26
|
-
// branch line looks like "branch refs/heads/
|
|
26
|
+
// branch line looks like "branch refs/heads/multiarena/..."
|
|
27
27
|
const ref = line.slice("branch ".length);
|
|
28
28
|
const branchName = ref.replace("refs/heads/", "");
|
|
29
29
|
registeredBranches.add(branchName);
|
|
@@ -33,10 +33,10 @@ export class WorktreeManager {
|
|
|
33
33
|
catch {
|
|
34
34
|
return cleaned;
|
|
35
35
|
}
|
|
36
|
-
// Remove orphaned
|
|
36
|
+
// Remove orphaned multiarena branches (branch exists but no worktree)
|
|
37
37
|
const branches = await this.git.branchLocal();
|
|
38
38
|
for (const branch of branches.all) {
|
|
39
|
-
if (!branch.startsWith("
|
|
39
|
+
if (!branch.startsWith("multiarena/"))
|
|
40
40
|
continue;
|
|
41
41
|
if (!registeredBranches.has(branch)) {
|
|
42
42
|
await this.git.deleteLocalBranch(branch, true).catch(() => { });
|
|
@@ -44,7 +44,7 @@ export class WorktreeManager {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
// Remove orphaned worktree directories (dir exists but not registered)
|
|
47
|
-
const arenaDir = path.join(os.tmpdir(), "
|
|
47
|
+
const arenaDir = path.join(os.tmpdir(), "multiarena-worktrees");
|
|
48
48
|
if (fs.existsSync(arenaDir)) {
|
|
49
49
|
let entries = [];
|
|
50
50
|
try {
|
|
@@ -67,10 +67,10 @@ export class WorktreeManager {
|
|
|
67
67
|
return cleaned;
|
|
68
68
|
}
|
|
69
69
|
async setup(taskId, modelNames) {
|
|
70
|
-
const baseName = `
|
|
70
|
+
const baseName = `multiarena/${taskId}`;
|
|
71
71
|
for (const name of modelNames) {
|
|
72
72
|
const branchName = `${baseName}-${name}`;
|
|
73
|
-
const worktreePath = path.join(os.tmpdir(), "
|
|
73
|
+
const worktreePath = path.join(os.tmpdir(), "multiarena-worktrees", `${taskId}-${name}`);
|
|
74
74
|
// Ensure the parent directory exists; git worktree add creates the leaf directory
|
|
75
75
|
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
76
76
|
// Remove leftover directory from a previous run that wasn't cleaned up
|
|
@@ -109,7 +109,7 @@ export class WorktreeManager {
|
|
|
109
109
|
fs.rmSync(wtPath, { recursive: true, force: true });
|
|
110
110
|
}
|
|
111
111
|
await this.git
|
|
112
|
-
.deleteLocalBranch(`
|
|
112
|
+
.deleteLocalBranch(`multiarena/${taskId}-${modelName}`, true)
|
|
113
113
|
.catch(() => { });
|
|
114
114
|
this.worktrees.delete(modelName);
|
|
115
115
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
|
-
const SESSIONS_DIR = path.join(os.homedir(), ".
|
|
4
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".multiarena", "sessions");
|
|
5
5
|
export function saveSession(session) {
|
|
6
6
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
7
7
|
const filePath = path.join(SESSIONS_DIR, `${session.id}.json`);
|
|
@@ -20,11 +20,12 @@ export class OpenAIProvider {
|
|
|
20
20
|
stream: true,
|
|
21
21
|
stream_options: { include_usage: true },
|
|
22
22
|
}, { signal: abortController.signal });
|
|
23
|
-
// Track pending tool calls across stream chunks
|
|
24
23
|
const pendingToolCalls = new Map();
|
|
25
24
|
let doneYielded = false;
|
|
25
|
+
// Stateful think-tag filter for reasoning models (e.g. MiniMax, DeepSeek-R1)
|
|
26
|
+
let inThink = false;
|
|
27
|
+
let thinkBuf = "";
|
|
26
28
|
for await (const chunk of stream) {
|
|
27
|
-
// Token usage chunk (when stream_options.include_usage is true)
|
|
28
29
|
if (chunk.usage) {
|
|
29
30
|
inputTokens = chunk.usage.prompt_tokens;
|
|
30
31
|
outputTokens = chunk.usage.completion_tokens;
|
|
@@ -34,7 +35,6 @@ export class OpenAIProvider {
|
|
|
34
35
|
if (!choice)
|
|
35
36
|
continue;
|
|
36
37
|
const delta = choice.delta;
|
|
37
|
-
// Accumulate tool call deltas
|
|
38
38
|
if (delta.tool_calls) {
|
|
39
39
|
for (const tc of delta.tool_calls) {
|
|
40
40
|
const idx = tc.index;
|
|
@@ -52,11 +52,47 @@ export class OpenAIProvider {
|
|
|
52
52
|
pendingToolCalls.set(idx, existing);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
// Text delta
|
|
55
|
+
// Text delta with think-tag filtering (for reasoning models)
|
|
56
56
|
if (delta.content) {
|
|
57
|
-
|
|
57
|
+
let text = delta.content;
|
|
58
|
+
if (inThink) {
|
|
59
|
+
thinkBuf += text;
|
|
60
|
+
const endIdx = thinkBuf.indexOf("</think>");
|
|
61
|
+
if (endIdx !== -1) {
|
|
62
|
+
inThink = false;
|
|
63
|
+
text = thinkBuf.slice(endIdx + "</think>".length);
|
|
64
|
+
thinkBuf = "";
|
|
65
|
+
if (!text)
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Check for <think> opening tag
|
|
73
|
+
const startIdx = text.indexOf("<think>");
|
|
74
|
+
if (startIdx !== -1) {
|
|
75
|
+
const before = text.slice(0, startIdx);
|
|
76
|
+
const rest = text.slice(startIdx + "<think>".length);
|
|
77
|
+
const endIdx = rest.indexOf("</think>");
|
|
78
|
+
if (endIdx !== -1) {
|
|
79
|
+
// Complete think block in this chunk
|
|
80
|
+
const after = rest.slice(endIdx + "</think>".length);
|
|
81
|
+
text = before + after;
|
|
82
|
+
if (!text)
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Think block spans chunks
|
|
87
|
+
if (before)
|
|
88
|
+
yield { type: "text", content: before };
|
|
89
|
+
inThink = true;
|
|
90
|
+
thinkBuf = rest;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
yield { type: "text", content: text };
|
|
58
95
|
}
|
|
59
|
-
// On finish_reason === "tool_calls", emit all pending tool_calls
|
|
60
96
|
if (choice.finish_reason === "tool_calls") {
|
|
61
97
|
for (const [, tc] of pendingToolCalls) {
|
|
62
98
|
yield {
|
|
@@ -73,8 +109,18 @@ export class OpenAIProvider {
|
|
|
73
109
|
};
|
|
74
110
|
doneYielded = true;
|
|
75
111
|
}
|
|
76
|
-
// handle stop
|
|
77
112
|
if (choice.finish_reason === "stop") {
|
|
113
|
+
// Flush any remaining think buffer
|
|
114
|
+
if (inThink && thinkBuf) {
|
|
115
|
+
const endIdx = thinkBuf.indexOf("</think>");
|
|
116
|
+
if (endIdx !== -1) {
|
|
117
|
+
const after = thinkBuf.slice(endIdx + "</think>".length);
|
|
118
|
+
if (after)
|
|
119
|
+
yield { type: "text", content: after };
|
|
120
|
+
}
|
|
121
|
+
inThink = false;
|
|
122
|
+
thinkBuf = "";
|
|
123
|
+
}
|
|
78
124
|
yield {
|
|
79
125
|
type: "done",
|
|
80
126
|
usage: { input: inputTokens, output: outputTokens },
|
|
@@ -82,7 +128,6 @@ export class OpenAIProvider {
|
|
|
82
128
|
doneYielded = true;
|
|
83
129
|
}
|
|
84
130
|
}
|
|
85
|
-
// Ensure done is always yielded (e.g. when stream ends on usage chunk)
|
|
86
131
|
if (!doneYielded) {
|
|
87
132
|
yield {
|
|
88
133
|
type: "done",
|
|
@@ -15,6 +15,10 @@ export function createProvider(config) {
|
|
|
15
15
|
const baseURL = config.endpoint?.replace(/\/v1\/?$/, "") ?? "http://localhost:11434";
|
|
16
16
|
return new OllamaProvider(baseURL);
|
|
17
17
|
}
|
|
18
|
+
case "deepseek":
|
|
19
|
+
return new OpenAIProvider(key, config.endpoint ?? "https://api.deepseek.com/v1");
|
|
20
|
+
case "minimax":
|
|
21
|
+
return new OpenAIProvider(key, config.endpoint ?? "https://api.minimax.chat/v1");
|
|
18
22
|
default:
|
|
19
23
|
throw new Error(`Unknown provider: ${config.provider}`);
|
|
20
24
|
}
|
|
@@ -13,6 +13,9 @@ export const bashTool = {
|
|
|
13
13
|
},
|
|
14
14
|
async execute(args, worktreePath) {
|
|
15
15
|
const command = args.command;
|
|
16
|
+
if (!command || command.trim() === "") {
|
|
17
|
+
return "Error: no command provided";
|
|
18
|
+
}
|
|
16
19
|
const dangerous = ["rm -rf /", "sudo ", "mkfs.", "dd if=", "> /dev/sda"];
|
|
17
20
|
for (const d of dangerous) {
|
|
18
21
|
if (command.includes(d))
|
|
@@ -24,11 +27,13 @@ export const bashTool = {
|
|
|
24
27
|
encoding: "utf-8",
|
|
25
28
|
timeout: 30000,
|
|
26
29
|
maxBuffer: 1024 * 1024,
|
|
30
|
+
shell: "bash",
|
|
27
31
|
});
|
|
28
32
|
return output || "(no output)";
|
|
29
33
|
}
|
|
30
34
|
catch (err) {
|
|
31
|
-
|
|
35
|
+
const detail = (err.stderr || err.message || "unknown error").trim();
|
|
36
|
+
return `Command failed (exit ${err.status ?? "?"}): ${detail}\n[cmd] ${command}`;
|
|
32
37
|
}
|
|
33
38
|
},
|
|
34
39
|
};
|
package/dist/ui/app.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
-
import { StatusBar } from "./components/StatusBar.js";
|
|
4
3
|
import { OutputArea } from "./components/OutputArea.js";
|
|
5
4
|
import { InputBar } from "./components/InputBar.js";
|
|
6
|
-
import { Session } from "../core/session.js";
|
|
5
|
+
import { Session, contextLimitForModel } from "../core/session.js";
|
|
7
6
|
import { loadConfig, validateConfig } from "../config/loader.js";
|
|
8
7
|
import { createDefaultRegistry } from "../tools/registry.js";
|
|
9
8
|
import { PermissionManager } from "../tools/permission.js";
|
|
10
9
|
import { runTurn } from "../core/turn.js";
|
|
11
10
|
import { WorktreeManager } from "../isolation/worktree.js";
|
|
12
11
|
import { saveSession, loadSession } from "../persistence/session.js";
|
|
13
|
-
|
|
12
|
+
function makeSystemPrompt(modelName, provider) {
|
|
13
|
+
return `You are a helpful AI coding assistant. You are the "${modelName}" model (provider: ${provider}). Be concise.`;
|
|
14
|
+
}
|
|
14
15
|
// App-level (module-scoped) tool registry and permission manager.
|
|
15
16
|
// Created once and shared across all submissions.
|
|
16
17
|
const toolRegistry = createDefaultRegistry();
|
|
@@ -34,7 +35,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
34
35
|
muted: false,
|
|
35
36
|
buffer: "",
|
|
36
37
|
usage: { input: 0, output: 0 },
|
|
37
|
-
contextLimit:
|
|
38
|
+
contextLimit: contextLimitForModel(config.models[m.name]?.provider ?? "", config.models[m.name]?.context_limit),
|
|
38
39
|
})),
|
|
39
40
|
targetMode: saved.lastTarget === "broadcast"
|
|
40
41
|
? { type: "broadcast" }
|
|
@@ -50,6 +51,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
50
51
|
const [scrollOffsets, setScrollOffsets] = useState({});
|
|
51
52
|
const [modelStates, setModelStates] = useState(() => session.models);
|
|
52
53
|
const [comparisonModel, setComparisonModel] = useState(null);
|
|
54
|
+
const comparisonFromBroadcastRef = useRef(false);
|
|
53
55
|
const activeScrollModel = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
54
56
|
const adjustScroll = useCallback((delta) => {
|
|
55
57
|
const modelName = activeScrollModel;
|
|
@@ -132,13 +134,19 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
132
134
|
if (key.tab) {
|
|
133
135
|
session.cycleTarget();
|
|
134
136
|
setComparisonModel(null);
|
|
137
|
+
comparisonFromBroadcastRef.current = false;
|
|
135
138
|
setModelStates([...session.models]);
|
|
136
139
|
return;
|
|
137
140
|
}
|
|
138
|
-
// Escape dismisses comparison mode
|
|
141
|
+
// Escape dismisses comparison mode (restore broadcast if entered from there)
|
|
139
142
|
if (key.escape) {
|
|
140
143
|
if (comparisonModel) {
|
|
144
|
+
if (comparisonFromBroadcastRef.current) {
|
|
145
|
+
session.setTarget({ type: "broadcast" });
|
|
146
|
+
comparisonFromBroadcastRef.current = false;
|
|
147
|
+
}
|
|
141
148
|
setComparisonModel(null);
|
|
149
|
+
setModelStates([...session.models]);
|
|
142
150
|
shortcutHandledRef.current = true;
|
|
143
151
|
return;
|
|
144
152
|
}
|
|
@@ -186,14 +194,28 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
186
194
|
// 'd' — toggle comparison mode (current model vs next unmuted model)
|
|
187
195
|
if (inputValue === "d") {
|
|
188
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
|
+
}
|
|
189
202
|
setComparisonModel(null);
|
|
190
203
|
}
|
|
191
204
|
else {
|
|
192
205
|
const unmuted = session.models.filter((m) => !m.muted);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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;
|
|
197
219
|
const idx = unmuted.findIndex((m) => m.name === baseName);
|
|
198
220
|
const next = unmuted[(idx + 1) % unmuted.length];
|
|
199
221
|
if (next && next.name !== baseName) {
|
|
@@ -201,6 +223,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
201
223
|
}
|
|
202
224
|
}
|
|
203
225
|
}
|
|
226
|
+
setModelStates([...session.models]);
|
|
204
227
|
shortcutHandledRef.current = true;
|
|
205
228
|
return;
|
|
206
229
|
}
|
|
@@ -210,6 +233,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
210
233
|
session.toggleMute(session.targetMode.modelName);
|
|
211
234
|
setModelStates([...session.models]);
|
|
212
235
|
setComparisonModel(null);
|
|
236
|
+
comparisonFromBroadcastRef.current = false;
|
|
213
237
|
}
|
|
214
238
|
shortcutHandledRef.current = true;
|
|
215
239
|
return;
|
|
@@ -272,7 +296,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
272
296
|
modelName: m.name,
|
|
273
297
|
config: mc,
|
|
274
298
|
messages: m.messages,
|
|
275
|
-
systemPrompt:
|
|
299
|
+
systemPrompt: makeSystemPrompt(m.name, mc.provider),
|
|
276
300
|
tools: toolRegistry.getDefinitions(),
|
|
277
301
|
registry: toolRegistry,
|
|
278
302
|
permission: permissionManager,
|
|
@@ -300,10 +324,6 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
300
324
|
saveCurrentSession();
|
|
301
325
|
}, [session, config, sessionId, saveCurrentSession]);
|
|
302
326
|
const terminalWidth = process.stdout.columns ?? 80;
|
|
303
|
-
const contextUsages = {};
|
|
304
|
-
for (const m of modelStates) {
|
|
305
|
-
contextUsages[m.name] = session.getContextUsage(m.name);
|
|
306
|
-
}
|
|
307
327
|
// ── No models configured: show startup guide ──────────────────
|
|
308
328
|
if (modelStates.length === 0) {
|
|
309
329
|
const example = `[models.claude]
|
|
@@ -316,28 +336,37 @@ provider = "openai"
|
|
|
316
336
|
model = "gpt-4o"
|
|
317
337
|
api_key = "\${OPENAI_API_KEY}"
|
|
318
338
|
|
|
339
|
+
[models.deepseek]
|
|
340
|
+
provider = "deepseek"
|
|
341
|
+
model = "deepseek-chat"
|
|
342
|
+
api_key = "\${DEEPSEEK_API_KEY}"
|
|
343
|
+
|
|
344
|
+
[models.minimax]
|
|
345
|
+
provider = "minimax"
|
|
346
|
+
model = "MiniMax-M2.1"
|
|
347
|
+
api_key = "\${MINIMAX_API_KEY}"
|
|
348
|
+
|
|
319
349
|
[defaults]
|
|
320
350
|
active = ["claude", "gpt"]
|
|
321
351
|
broadcast = true`;
|
|
322
352
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
323
|
-
React.createElement(Text, { bold: true, color: "cyan" }, "
|
|
353
|
+
React.createElement(Text, { bold: true, color: "cyan" }, "multiarena \u2014 Multi-Model AI Coding Assistant"),
|
|
324
354
|
React.createElement(Text, null, " "),
|
|
325
355
|
React.createElement(Text, null,
|
|
326
356
|
"No models configured. Create a ",
|
|
327
|
-
React.createElement(Text, { color: "yellow" }, ".
|
|
357
|
+
React.createElement(Text, { color: "yellow" }, ".multiarenarc"),
|
|
328
358
|
" file in your project root or home directory:"),
|
|
329
359
|
React.createElement(Text, null, " "),
|
|
330
360
|
React.createElement(Text, { color: "gray" }, example),
|
|
331
361
|
React.createElement(Text, null, " "),
|
|
332
|
-
React.createElement(Text, { dimColor: true }, "Supported providers: anthropic, openai, google, ollama")));
|
|
362
|
+
React.createElement(Text, { dimColor: true }, "Supported providers: anthropic, openai, google, ollama, deepseek, minimax")));
|
|
333
363
|
}
|
|
334
364
|
return (React.createElement(Box, { flexDirection: "column", width: "100%" },
|
|
335
|
-
React.createElement(StatusBar, { models: modelStates, activeModelName: activeModelName, contextUsages: contextUsages }),
|
|
336
365
|
configWarnings.length > 0 && (React.createElement(Box, { flexDirection: "column" }, configWarnings.map((w, i) => (React.createElement(Text, { key: i, color: "yellow" },
|
|
337
366
|
"\u26A0 ",
|
|
338
367
|
w.message))))),
|
|
339
368
|
React.createElement(Text, null, "─".repeat(terminalWidth)),
|
|
340
|
-
React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel }),
|
|
369
|
+
React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel, terminalWidth: terminalWidth }),
|
|
341
370
|
React.createElement(Text, null, "─".repeat(terminalWidth)),
|
|
342
|
-
React.createElement(InputBar, { prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
|
|
371
|
+
React.createElement(InputBar, { models: modelStates, activeModelName: activeModelName, prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
|
|
343
372
|
};
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { formatTokens } from "./StatusBar.js";
|
|
3
4
|
const PANEL_LINES = 4;
|
|
4
|
-
export const BroadcastSummary = ({ models }) => {
|
|
5
|
+
export const BroadcastSummary = ({ models, terminalWidth }) => {
|
|
5
6
|
const activeModels = models.filter((m) => !m.muted);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
7
|
+
const panelWidth = Math.floor(terminalWidth / activeModels.length);
|
|
8
|
+
return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 }, activeModels.map((m, idx) => {
|
|
9
|
+
const rawLines = m.buffer ? m.buffer.split("\n") : [];
|
|
10
|
+
const totalLines = rawLines.length;
|
|
11
|
+
const isEmpty = totalLines === 0 || (totalLines === 1 && rawLines[0].trim() === "");
|
|
12
|
+
const displayLines = rawLines.slice(-PANEL_LINES);
|
|
13
|
+
while (displayLines.length < PANEL_LINES) {
|
|
14
|
+
displayLines.unshift("");
|
|
15
|
+
}
|
|
16
|
+
const isLast = idx === activeModels.length - 1;
|
|
17
|
+
return (React.createElement(Box, { key: m.name, flexDirection: "column", width: panelWidth, borderStyle: "single", borderColor: "gray", marginRight: isLast ? 0 : 1 },
|
|
10
18
|
React.createElement(Text, { bold: true }, m.name),
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
displayLines.map((line, i) => {
|
|
20
|
+
if (isEmpty && i === 0) {
|
|
21
|
+
return (React.createElement(Text, { key: i, dimColor: true }, m.isStreaming ? "Waiting..." : "No output"));
|
|
22
|
+
}
|
|
23
|
+
return (React.createElement(Text, { key: i, wrap: "truncate" }, line || " "));
|
|
24
|
+
}),
|
|
13
25
|
React.createElement(Text, { dimColor: true },
|
|
14
|
-
totalLines,
|
|
26
|
+
isEmpty ? 0 : totalLines,
|
|
15
27
|
" lines \u00B7 ",
|
|
28
|
+
formatTokens(m.usage.input + m.usage.output),
|
|
29
|
+
"/",
|
|
30
|
+
formatTokens(m.contextLimit),
|
|
31
|
+
" \u00B7 ",
|
|
16
32
|
m.isStreaming ? "streaming..." : "done")));
|
|
17
33
|
})));
|
|
18
34
|
};
|
|
@@ -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 Tab:switch 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 "./StatusBar.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
|
};
|
|
@@ -2,9 +2,9 @@ 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
|
+
export const OutputArea = ({ models, targetMode, scrollOffsets, comparisonModel, terminalWidth, }) => {
|
|
6
6
|
if (targetMode.type === "broadcast") {
|
|
7
|
-
return React.createElement(BroadcastSummary, { models: models });
|
|
7
|
+
return React.createElement(BroadcastSummary, { models: models, terminalWidth: terminalWidth });
|
|
8
8
|
}
|
|
9
9
|
const activeModel = models.find((m) => m.name === targetMode.modelName);
|
|
10
10
|
if (!activeModel) {
|
|
@@ -19,10 +19,8 @@ export const OutputArea = ({ models, targetMode, scrollOffsets, comparisonModel,
|
|
|
19
19
|
}
|
|
20
20
|
return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 },
|
|
21
21
|
React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginRight: 1 },
|
|
22
|
-
React.createElement(Text, { bold: true }, activeModel.name),
|
|
23
22
|
React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 })),
|
|
24
23
|
React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
|
|
25
|
-
React.createElement(Text, { bold: true }, compModel.name),
|
|
26
24
|
React.createElement(ModelDetail, { model: compModel, scrollOffset: scrollOffsets[compModel.name] ?? 0 }))));
|
|
27
25
|
}
|
|
28
26
|
return React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 });
|
|
@@ -5,7 +5,7 @@ function renderBar(ratio) {
|
|
|
5
5
|
const filled = Math.round(ratio * blocks);
|
|
6
6
|
return "█".repeat(filled) + "░".repeat(blocks - filled);
|
|
7
7
|
}
|
|
8
|
-
function formatTokens(n) {
|
|
8
|
+
export function formatTokens(n) {
|
|
9
9
|
if (n >= 1_000_000)
|
|
10
10
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
11
11
|
if (n >= 1_000)
|
|
@@ -21,12 +21,11 @@ function barColor(ratio) {
|
|
|
21
21
|
}
|
|
22
22
|
export const StatusBar = ({ models, activeModelName, contextUsages }) => (React.createElement(Box, { flexDirection: "column" },
|
|
23
23
|
React.createElement(Box, { height: 1, flexDirection: "row" }, models.map((m) => {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const color = isActive ? "green" : "white";
|
|
24
|
+
const isTargeted = activeModelName === null || activeModelName === m.name;
|
|
25
|
+
const color = isTargeted ? "green" : "white";
|
|
27
26
|
return (React.createElement(Box, { key: m.name, marginRight: 1 },
|
|
28
|
-
React.createElement(Text, { color: color, bold:
|
|
29
|
-
|
|
27
|
+
React.createElement(Text, { color: color, bold: isTargeted }, m.name),
|
|
28
|
+
isTargeted && !m.muted && React.createElement(Text, { color: "yellow" }, " \u25CF"),
|
|
30
29
|
m.muted && React.createElement(Text, { color: "gray" }, " [muted]")));
|
|
31
30
|
})),
|
|
32
31
|
React.createElement(Box, { height: 1, flexDirection: "row" }, models.map((m) => {
|