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.
@@ -48,8 +48,8 @@ export function validateConfig(config) {
48
48
  }
49
49
  export function loadConfig() {
50
50
  const candidates = [
51
- path.join(process.cwd(), ".arenarc"),
52
- path.join(os.homedir(), ".arenarc"),
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) {
@@ -1,5 +1,5 @@
1
1
  export interface ModelConfig {
2
- provider: "anthropic" | "openai" | "google" | "ollama";
2
+ provider: "anthropic" | "openai" | "google" | "ollama" | "deepseek" | "minimax";
3
3
  model: string;
4
4
  api_key?: string;
5
5
  endpoint?: string;
@@ -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;
@@ -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?.model ?? "", mc?.context_limit),
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(model, explicit) {
144
+ export function contextLimitForModel(provider, explicit) {
142
145
  if (explicit && explicit > 0)
143
146
  return explicit;
144
- if (model.includes("claude"))
145
- return 200000;
146
- if (model.includes("gpt-4"))
147
- return 128000;
148
- if (model.includes("gpt-3.5"))
149
- return 16384;
150
- if (model.includes("gemini"))
151
- return 1048576;
152
- if (model.includes("deepseek"))
153
- return 128000;
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
  }
@@ -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
- const toolLabel = `\n[Tool: ${tc.name}]\n`;
69
- allText.push(toolLabel);
70
- yield { type: "text", content: toolLabel };
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 = `Arena — Multi-Model AI Coding Assistant
20
+ const HELP = `multiarena — Multi-Model AI Coding Assistant
21
21
 
22
22
  Usage:
23
- arena [options]
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(`arena v${PKG_VERSION}`);
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 arena branches and worktree directories from prior crashes. */
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 arena branches and worktree directories from prior crashes. */
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/arena/..."
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 arena branches (branch exists but no worktree)
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("arena/"))
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(), "arena-worktrees");
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 = `arena/${taskId}`;
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(), "arena-worktrees", `${taskId}-${name}`);
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(`arena/${taskId}-${modelName}`, true)
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(), ".arena", "sessions");
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
- yield { type: "text", content: delta.content };
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
- return `Command failed (exit ${err.status}): ${err.stderr ?? err.message}`;
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
- const SYSTEM_PROMPT = "You are a helpful AI coding assistant. Be concise.";
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: 128000,
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
- const baseName = session.targetMode.type === "directed"
194
- ? session.targetMode.modelName
195
- : unmuted[0]?.name;
196
- if (baseName && unmuted.length >= 2) {
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: SYSTEM_PROMPT,
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" }, "Arena \u2014 Multi-Model AI Coding Assistant"),
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" }, ".arenarc"),
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
  };
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import type { ModelState } from "../../core/types.js";
3
3
  interface Props {
4
4
  models: ModelState[];
5
+ terminalWidth: number;
5
6
  }
6
7
  export declare const BroadcastSummary: React.FC<Props>;
7
8
  export {};
@@ -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
- return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 }, activeModels.map((m) => {
7
- const lines = m.buffer.split("\n").slice(0, PANEL_LINES);
8
- const totalLines = m.buffer.split("\n").length;
9
- return (React.createElement(Box, { key: m.name, flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: "gray", marginRight: 1 },
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
- lines.map((line, i) => (React.createElement(Text, { key: i, wrap: "truncate" }, line || " "))),
12
- totalLines === 0 && (React.createElement(Text, { dimColor: true }, "Waiting...")),
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,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 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
- 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
  };
@@ -10,6 +10,7 @@ interface Props {
10
10
  };
11
11
  scrollOffsets: Record<string, number>;
12
12
  comparisonModel?: string | null;
13
+ terminalWidth: number;
13
14
  }
14
15
  export declare const OutputArea: React.FC<Props>;
15
16
  export {};
@@ -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,5 +5,6 @@ interface Props {
5
5
  activeModelName: string | null;
6
6
  contextUsages: Record<string, number>;
7
7
  }
8
+ export declare function formatTokens(n: number): string;
8
9
  export declare const StatusBar: React.FC<Props>;
9
10
  export {};
@@ -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 isActive = activeModelName === m.name;
25
- const hasNew = m.buffer.length > 0 && !isActive;
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: isActive }, m.name),
29
- hasNew && React.createElement(Text, { color: "yellow" }, " \u25CF"),
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiarena",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Terminal-native multi-model AI coding assistant — chat with multiple LLMs side by side",
5
5
  "type": "module",
6
6
  "license": "MIT",