multiarena 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/config/loader.d.ts +6 -0
  2. package/dist/config/loader.js +69 -0
  3. package/dist/config/types.d.ts +15 -0
  4. package/dist/config/types.js +6 -0
  5. package/dist/core/session.d.ts +40 -0
  6. package/dist/core/session.js +155 -0
  7. package/dist/core/turn.d.ts +31 -0
  8. package/dist/core/turn.js +112 -0
  9. package/dist/core/types.d.ts +25 -0
  10. package/dist/core/types.js +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +76 -0
  13. package/dist/isolation/worktree.d.ts +11 -0
  14. package/dist/isolation/worktree.js +117 -0
  15. package/dist/persistence/session.d.ts +17 -0
  16. package/dist/persistence/session.js +27 -0
  17. package/dist/provider/adapters/anthropic.d.ts +11 -0
  18. package/dist/provider/adapters/anthropic.js +146 -0
  19. package/dist/provider/adapters/google.d.ts +11 -0
  20. package/dist/provider/adapters/google.js +177 -0
  21. package/dist/provider/adapters/ollama.d.ts +11 -0
  22. package/dist/provider/adapters/ollama.js +147 -0
  23. package/dist/provider/adapters/openai.d.ts +11 -0
  24. package/dist/provider/adapters/openai.js +167 -0
  25. package/dist/provider/provider.d.ts +7 -0
  26. package/dist/provider/provider.js +21 -0
  27. package/dist/provider/types.d.ts +41 -0
  28. package/dist/provider/types.js +1 -0
  29. package/dist/tools/builtin/bash.d.ts +2 -0
  30. package/dist/tools/builtin/bash.js +34 -0
  31. package/dist/tools/builtin/editFile.d.ts +2 -0
  32. package/dist/tools/builtin/editFile.js +40 -0
  33. package/dist/tools/builtin/glob.d.ts +2 -0
  34. package/dist/tools/builtin/glob.js +77 -0
  35. package/dist/tools/builtin/grep.d.ts +2 -0
  36. package/dist/tools/builtin/grep.js +120 -0
  37. package/dist/tools/builtin/readFile.d.ts +2 -0
  38. package/dist/tools/builtin/readFile.js +27 -0
  39. package/dist/tools/builtin/writeFile.d.ts +2 -0
  40. package/dist/tools/builtin/writeFile.js +29 -0
  41. package/dist/tools/permission.d.ts +7 -0
  42. package/dist/tools/permission.js +31 -0
  43. package/dist/tools/registry.d.ts +9 -0
  44. package/dist/tools/registry.js +37 -0
  45. package/dist/tools/types.d.ts +11 -0
  46. package/dist/tools/types.js +1 -0
  47. package/dist/ui/app.d.ts +4 -0
  48. package/dist/ui/app.js +343 -0
  49. package/dist/ui/components/BroadcastSummary.d.ts +7 -0
  50. package/dist/ui/components/BroadcastSummary.js +18 -0
  51. package/dist/ui/components/InputBar.d.ts +9 -0
  52. package/dist/ui/components/InputBar.js +11 -0
  53. package/dist/ui/components/ModelDetail.d.ts +8 -0
  54. package/dist/ui/components/ModelDetail.js +13 -0
  55. package/dist/ui/components/OutputArea.d.ts +15 -0
  56. package/dist/ui/components/OutputArea.js +29 -0
  57. package/dist/ui/components/StatusBar.d.ts +9 -0
  58. package/dist/ui/components/StatusBar.js +51 -0
  59. package/package.json +60 -0
@@ -0,0 +1,37 @@
1
+ export class ToolRegistry {
2
+ tools = new Map();
3
+ register(handler) {
4
+ this.tools.set(handler.definition.name, handler);
5
+ }
6
+ getDefinitions() {
7
+ return Array.from(this.tools.values()).map((h) => h.definition);
8
+ }
9
+ async execute(name, args, worktreePath) {
10
+ const handler = this.tools.get(name);
11
+ if (!handler) {
12
+ return `Error: unknown tool "${name}"`;
13
+ }
14
+ try {
15
+ return await handler.execute(args, worktreePath);
16
+ }
17
+ catch (err) {
18
+ return `Error executing ${name}: ${err.message}`;
19
+ }
20
+ }
21
+ }
22
+ import { readFileTool } from "./builtin/readFile.js";
23
+ import { grepTool } from "./builtin/grep.js";
24
+ import { bashTool } from "./builtin/bash.js";
25
+ import { globTool } from "./builtin/glob.js";
26
+ import { writeFileTool } from "./builtin/writeFile.js";
27
+ import { editFileTool } from "./builtin/editFile.js";
28
+ export function createDefaultRegistry() {
29
+ const registry = new ToolRegistry();
30
+ registry.register(readFileTool);
31
+ registry.register(grepTool);
32
+ registry.register(bashTool);
33
+ registry.register(globTool);
34
+ registry.register(writeFileTool);
35
+ registry.register(editFileTool);
36
+ return registry;
37
+ }
@@ -0,0 +1,11 @@
1
+ import type { ToolDef } from "../provider/types.js";
2
+ export interface ToolHandler {
3
+ definition: ToolDef;
4
+ execute(args: Record<string, unknown>, worktreePath: string): Promise<string>;
5
+ }
6
+ export type PermissionDecision = "allow" | "deny" | "allow_always" | "deny_always";
7
+ export interface PermissionEntry {
8
+ toolName: string;
9
+ args: Record<string, unknown>;
10
+ decision: "allow_always" | "deny_always";
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import React from "react";
2
+ export declare const App: React.FC<{
3
+ sessionId?: string;
4
+ }>;
package/dist/ui/app.js ADDED
@@ -0,0 +1,343 @@
1
+ import React, { useState, useCallback, useEffect, useRef } from "react";
2
+ import { Box, Text, useInput, useApp } from "ink";
3
+ import { StatusBar } from "./components/StatusBar.js";
4
+ import { OutputArea } from "./components/OutputArea.js";
5
+ import { InputBar } from "./components/InputBar.js";
6
+ import { Session } from "../core/session.js";
7
+ import { loadConfig, validateConfig } from "../config/loader.js";
8
+ import { createDefaultRegistry } from "../tools/registry.js";
9
+ import { PermissionManager } from "../tools/permission.js";
10
+ import { runTurn } from "../core/turn.js";
11
+ import { WorktreeManager } from "../isolation/worktree.js";
12
+ import { saveSession, loadSession } from "../persistence/session.js";
13
+ const SYSTEM_PROMPT = "You are a helpful AI coding assistant. Be concise.";
14
+ // App-level (module-scoped) tool registry and permission manager.
15
+ // Created once and shared across all submissions.
16
+ const toolRegistry = createDefaultRegistry();
17
+ const permissionManager = new PermissionManager();
18
+ export const App = ({ sessionId: initialSessionId }) => {
19
+ const config = loadConfig();
20
+ const configWarnings = validateConfig(config);
21
+ const { exit } = useApp();
22
+ // Generate or reuse session ID
23
+ const [sessionId] = useState(() => initialSessionId ?? Date.now().toString(36));
24
+ // Load saved session or create fresh one
25
+ const [session] = useState(() => {
26
+ if (initialSessionId) {
27
+ const saved = loadSession(initialSessionId);
28
+ if (saved) {
29
+ const snapshot = {
30
+ models: saved.models.map((m) => ({
31
+ name: m.name,
32
+ provider: config.models[m.name]?.provider ?? "unknown",
33
+ messages: m.messages,
34
+ muted: false,
35
+ buffer: "",
36
+ usage: { input: 0, output: 0 },
37
+ contextLimit: 128000,
38
+ })),
39
+ targetMode: saved.lastTarget === "broadcast"
40
+ ? { type: "broadcast" }
41
+ : { type: "directed", modelName: saved.lastTarget },
42
+ worktreeBase: process.cwd(),
43
+ };
44
+ return new Session(config, process.cwd(), snapshot);
45
+ }
46
+ }
47
+ return new Session(config, process.cwd());
48
+ });
49
+ const [input, setInput] = useState("");
50
+ const [scrollOffsets, setScrollOffsets] = useState({});
51
+ const [modelStates, setModelStates] = useState(() => session.models);
52
+ const [comparisonModel, setComparisonModel] = useState(null);
53
+ const activeScrollModel = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
54
+ const adjustScroll = useCallback((delta) => {
55
+ const modelName = activeScrollModel;
56
+ if (!modelName)
57
+ return;
58
+ setScrollOffsets((prev) => ({
59
+ ...prev,
60
+ [modelName]: Math.max(0, (prev[modelName] ?? 0) + delta),
61
+ }));
62
+ }, [activeScrollModel]);
63
+ // Input history
64
+ const inputHistoryRef = useRef([]);
65
+ const historyIdxRef = useRef(-1);
66
+ const handleInputChange = useCallback((value) => {
67
+ // Reset history navigation when user starts typing
68
+ if (historyIdxRef.current !== -1) {
69
+ historyIdxRef.current = -1;
70
+ }
71
+ setInput(value);
72
+ }, []);
73
+ // Track whether a shortcut key was just handled so we can clear the input
74
+ // bar in a post-render effect (avoids ink-text-input re-populating it).
75
+ const shortcutHandledRef = useRef(false);
76
+ // ── Save helper (used by handleSubmit and quit) ──────────────────
77
+ const saveCurrentSession = useCallback(() => {
78
+ const lastTarget = session.targetMode.type === "broadcast"
79
+ ? "broadcast"
80
+ : session.targetMode.modelName;
81
+ saveSession({
82
+ id: sessionId,
83
+ timestamp: new Date().toISOString(),
84
+ models: session.models.map((m) => ({
85
+ name: m.name,
86
+ messages: m.messages.map((msg) => ({
87
+ role: msg.role,
88
+ content: msg.content,
89
+ tool_call_id: msg.tool_call_id,
90
+ })),
91
+ buffer: m.buffer,
92
+ })),
93
+ lastTarget,
94
+ });
95
+ }, [session, sessionId]);
96
+ const targetPrefix = session.targetMode.type === "broadcast"
97
+ ? "all"
98
+ : session.targetMode.modelName;
99
+ const activeModelName = session.targetMode.type === "broadcast" ? null : session.targetMode.modelName;
100
+ // Save session on process exit (Ctrl+C, kill, etc.)
101
+ useEffect(() => {
102
+ const onExit = () => {
103
+ try {
104
+ saveCurrentSession();
105
+ }
106
+ catch {
107
+ // Best-effort save
108
+ }
109
+ };
110
+ process.on("exit", onExit);
111
+ return () => {
112
+ process.off("exit", onExit);
113
+ };
114
+ }, [saveCurrentSession]);
115
+ // Clean up orphaned worktrees from prior crashes on startup
116
+ useEffect(() => {
117
+ const wm = new WorktreeManager(process.cwd());
118
+ wm.sweepOrphans().catch(() => { });
119
+ }, []);
120
+ // Clear the input bar whenever a shortcut was handled (runs after the render
121
+ // batch so it overrides any concurrent setInput from ink-text-input).
122
+ useEffect(() => {
123
+ if (shortcutHandledRef.current) {
124
+ shortcutHandledRef.current = false;
125
+ setInput("");
126
+ }
127
+ });
128
+ // Keyboard input: Tab cycling, scrolling, and single-key shortcuts.
129
+ // Single-key shortcuts (d/m/r) only fire when the input bar is empty so they
130
+ // don't interfere with message typing.
131
+ useInput((inputValue, key) => {
132
+ if (key.tab) {
133
+ session.cycleTarget();
134
+ setComparisonModel(null);
135
+ setModelStates([...session.models]);
136
+ return;
137
+ }
138
+ // Escape dismisses comparison mode
139
+ if (key.escape) {
140
+ if (comparisonModel) {
141
+ setComparisonModel(null);
142
+ shortcutHandledRef.current = true;
143
+ return;
144
+ }
145
+ }
146
+ if (key.upArrow) {
147
+ if (input.length === 0) {
148
+ const history = inputHistoryRef.current;
149
+ if (history.length === 0)
150
+ return;
151
+ const idx = historyIdxRef.current === -1 ? history.length - 1 : Math.max(0, historyIdxRef.current - 1);
152
+ historyIdxRef.current = idx;
153
+ setInput(history[idx]);
154
+ }
155
+ else {
156
+ adjustScroll(-1);
157
+ }
158
+ return;
159
+ }
160
+ if (key.downArrow) {
161
+ if (input.length === 0) {
162
+ const history = inputHistoryRef.current;
163
+ if (historyIdxRef.current === -1)
164
+ return;
165
+ const idx = historyIdxRef.current + 1;
166
+ if (idx >= history.length) {
167
+ historyIdxRef.current = -1;
168
+ setInput("");
169
+ }
170
+ else {
171
+ historyIdxRef.current = idx;
172
+ setInput(history[idx]);
173
+ }
174
+ }
175
+ else {
176
+ adjustScroll(1);
177
+ }
178
+ return;
179
+ }
180
+ // Single-key shortcuts — only active when the input bar is empty
181
+ // so they don't collide with normal message typing.
182
+ if (input.length > 0)
183
+ return;
184
+ if (key.ctrl || key.meta)
185
+ return;
186
+ // 'd' — toggle comparison mode (current model vs next unmuted model)
187
+ if (inputValue === "d") {
188
+ if (comparisonModel) {
189
+ setComparisonModel(null);
190
+ }
191
+ else {
192
+ 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) {
197
+ const idx = unmuted.findIndex((m) => m.name === baseName);
198
+ const next = unmuted[(idx + 1) % unmuted.length];
199
+ if (next && next.name !== baseName) {
200
+ setComparisonModel(next.name);
201
+ }
202
+ }
203
+ }
204
+ shortcutHandledRef.current = true;
205
+ return;
206
+ }
207
+ // 'm' — mute/unmute the current directed model
208
+ if (inputValue === "m") {
209
+ if (session.targetMode.type === "directed") {
210
+ session.toggleMute(session.targetMode.modelName);
211
+ setModelStates([...session.models]);
212
+ setComparisonModel(null);
213
+ }
214
+ shortcutHandledRef.current = true;
215
+ return;
216
+ }
217
+ // 'r' — reset the current directed model's context (clear history & buffer)
218
+ if (inputValue === "r") {
219
+ if (session.targetMode.type === "directed") {
220
+ session.resetModel(session.targetMode.modelName);
221
+ setModelStates([...session.models]);
222
+ }
223
+ shortcutHandledRef.current = true;
224
+ return;
225
+ }
226
+ // 'q' — quit (save session and exit)
227
+ if (inputValue === "q") {
228
+ saveCurrentSession();
229
+ exit();
230
+ return;
231
+ }
232
+ });
233
+ const handleSubmit = useCallback(async (value) => {
234
+ const trimmed = value.trim();
235
+ if (!trimmed)
236
+ return;
237
+ // Add to input history
238
+ inputHistoryRef.current.push(trimmed);
239
+ historyIdxRef.current = -1;
240
+ setInput("");
241
+ if (activeScrollModel) {
242
+ setScrollOffsets((prev) => ({ ...prev, [activeScrollModel]: 0 }));
243
+ }
244
+ setComparisonModel(null);
245
+ // ── Worktree setup ──────────────────────────────────────────
246
+ const taskId = Date.now().toString(36);
247
+ const worktreeManager = new WorktreeManager(process.cwd());
248
+ const modelNames = session.models
249
+ .filter((m) => !m.muted)
250
+ .map((m) => m.name);
251
+ await worktreeManager.setup(taskId, modelNames);
252
+ // Add user message to target models
253
+ const targets = session.addUserMessage(trimmed);
254
+ setModelStates([...session.models]);
255
+ // Mark targets as streaming, clear buffers
256
+ for (const t of targets) {
257
+ t.isStreaming = true;
258
+ t.buffer = "";
259
+ }
260
+ setModelStates([...session.models]);
261
+ // Launch concurrent turns for all target models
262
+ await Promise.all(targets.map(async (m) => {
263
+ const mc = config.models[m.name];
264
+ if (!mc) {
265
+ m.buffer = `[Error: No config for model "${m.name}"]`;
266
+ m.isStreaming = false;
267
+ setModelStates([...session.models]);
268
+ return;
269
+ }
270
+ const worktreePath = worktreeManager.getWorktreePath(m.name) ?? process.cwd();
271
+ const stream = runTurn({
272
+ modelName: m.name,
273
+ config: mc,
274
+ messages: m.messages,
275
+ systemPrompt: SYSTEM_PROMPT,
276
+ tools: toolRegistry.getDefinitions(),
277
+ registry: toolRegistry,
278
+ permission: permissionManager,
279
+ worktreePath,
280
+ });
281
+ for await (const event of stream) {
282
+ if (event.type === "text") {
283
+ m.buffer += event.content;
284
+ }
285
+ else if (event.type === "done") {
286
+ m.usage.input += event.usage.input;
287
+ m.usage.output += event.usage.output;
288
+ m.isStreaming = false;
289
+ }
290
+ else if (event.type === "error") {
291
+ m.buffer += `\n[Error: ${event.message}]`;
292
+ m.isStreaming = false;
293
+ }
294
+ setModelStates([...session.models]);
295
+ }
296
+ }));
297
+ // ── Worktree cleanup (keep none by default) ─────────────────
298
+ await worktreeManager.cleanup(taskId);
299
+ // ── Auto-save session ─────────────────────────────────────
300
+ saveCurrentSession();
301
+ }, [session, config, sessionId, saveCurrentSession]);
302
+ 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
+ // ── No models configured: show startup guide ──────────────────
308
+ if (modelStates.length === 0) {
309
+ const example = `[models.claude]
310
+ provider = "anthropic"
311
+ model = "claude-sonnet-4-6"
312
+ api_key = "\${ANTHROPIC_API_KEY}"
313
+
314
+ [models.gpt]
315
+ provider = "openai"
316
+ model = "gpt-4o"
317
+ api_key = "\${OPENAI_API_KEY}"
318
+
319
+ [defaults]
320
+ active = ["claude", "gpt"]
321
+ broadcast = true`;
322
+ return (React.createElement(Box, { flexDirection: "column", padding: 1 },
323
+ React.createElement(Text, { bold: true, color: "cyan" }, "Arena \u2014 Multi-Model AI Coding Assistant"),
324
+ React.createElement(Text, null, " "),
325
+ React.createElement(Text, null,
326
+ "No models configured. Create a ",
327
+ React.createElement(Text, { color: "yellow" }, ".arenarc"),
328
+ " file in your project root or home directory:"),
329
+ React.createElement(Text, null, " "),
330
+ React.createElement(Text, { color: "gray" }, example),
331
+ React.createElement(Text, null, " "),
332
+ React.createElement(Text, { dimColor: true }, "Supported providers: anthropic, openai, google, ollama")));
333
+ }
334
+ return (React.createElement(Box, { flexDirection: "column", width: "100%" },
335
+ React.createElement(StatusBar, { models: modelStates, activeModelName: activeModelName, contextUsages: contextUsages }),
336
+ configWarnings.length > 0 && (React.createElement(Box, { flexDirection: "column" }, configWarnings.map((w, i) => (React.createElement(Text, { key: i, color: "yellow" },
337
+ "\u26A0 ",
338
+ w.message))))),
339
+ React.createElement(Text, null, "─".repeat(terminalWidth)),
340
+ React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel }),
341
+ React.createElement(Text, null, "─".repeat(terminalWidth)),
342
+ React.createElement(InputBar, { prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
343
+ };
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import type { ModelState } from "../../core/types.js";
3
+ interface Props {
4
+ models: ModelState[];
5
+ }
6
+ export declare const BroadcastSummary: React.FC<Props>;
7
+ export {};
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ const PANEL_LINES = 4;
4
+ export const BroadcastSummary = ({ models }) => {
5
+ 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 },
10
+ 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...")),
13
+ React.createElement(Text, { dimColor: true },
14
+ totalLines,
15
+ " lines \u00B7 ",
16
+ m.isStreaming ? "streaming..." : "done")));
17
+ })));
18
+ };
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ interface Props {
3
+ prefix: string;
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ onSubmit: (value: string) => void;
7
+ }
8
+ export declare const InputBar: React.FC<Props>;
9
+ export {};
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
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 })));
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import type { ModelState } from "../../core/types.js";
3
+ interface Props {
4
+ model: ModelState;
5
+ scrollOffset: number;
6
+ }
7
+ export declare const ModelDetail: React.FC<Props>;
8
+ export {};
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ export const ModelDetail = ({ model, scrollOffset }) => {
4
+ const allLines = model.buffer.split("\n");
5
+ 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")),
8
+ visibleLines.map((line, i) => {
9
+ const isError = /^\[?(?:Error|error)[:\]]/.test(line);
10
+ return (React.createElement(Text, { key: scrollOffset + i, color: isError ? "red" : undefined }, line || " "));
11
+ }),
12
+ model.isStreaming && React.createElement(Text, { color: "gray" }, "\u258B")));
13
+ };
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import type { ModelState } from "../../core/types.js";
3
+ interface Props {
4
+ models: ModelState[];
5
+ targetMode: {
6
+ type: "broadcast";
7
+ } | {
8
+ type: "directed";
9
+ modelName: string;
10
+ };
11
+ scrollOffsets: Record<string, number>;
12
+ comparisonModel?: string | null;
13
+ }
14
+ export declare const OutputArea: React.FC<Props>;
15
+ export {};
@@ -0,0 +1,29 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { BroadcastSummary } from "./BroadcastSummary.js";
4
+ import { ModelDetail } from "./ModelDetail.js";
5
+ export const OutputArea = ({ models, targetMode, scrollOffsets, comparisonModel, }) => {
6
+ if (targetMode.type === "broadcast") {
7
+ return React.createElement(BroadcastSummary, { models: models });
8
+ }
9
+ const activeModel = models.find((m) => m.name === targetMode.modelName);
10
+ if (!activeModel) {
11
+ return (React.createElement(Box, { flexGrow: 1 },
12
+ React.createElement(Text, null, "No model selected")));
13
+ }
14
+ // ── Comparison mode: two models side by side ──────────────────
15
+ if (comparisonModel) {
16
+ const compModel = models.find((m) => m.name === comparisonModel);
17
+ if (!compModel) {
18
+ return React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 });
19
+ }
20
+ return (React.createElement(Box, { flexDirection: "row", flexGrow: 1 },
21
+ React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginRight: 1 },
22
+ React.createElement(Text, { bold: true }, activeModel.name),
23
+ React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 })),
24
+ React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
25
+ React.createElement(Text, { bold: true }, compModel.name),
26
+ React.createElement(ModelDetail, { model: compModel, scrollOffset: scrollOffsets[compModel.name] ?? 0 }))));
27
+ }
28
+ return React.createElement(ModelDetail, { model: activeModel, scrollOffset: scrollOffsets[activeModel.name] ?? 0 });
29
+ };
@@ -0,0 +1,9 @@
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 {};
@@ -0,0 +1,51 @@
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"))));
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "multiarena",
3
+ "version": "0.1.0",
4
+ "description": "Terminal-native multi-model AI coding assistant — chat with multiple LLMs side by side",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "bin": {
11
+ "multiarena": "dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist/"
15
+ ],
16
+ "keywords": [
17
+ "ai",
18
+ "llm",
19
+ "coding-assistant",
20
+ "terminal",
21
+ "cli",
22
+ "claude",
23
+ "gpt",
24
+ "gemini",
25
+ "ollama",
26
+ "multi-model"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/timgunnar/arena"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "start": "node dist/index.js",
35
+ "dev": "tsx src/index.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "lint": "eslint src/"
39
+ },
40
+ "dependencies": {
41
+ "@anthropic-ai/sdk": "^0.30.0",
42
+ "@google/generative-ai": "^0.21.0",
43
+ "@iarna/toml": "^2.2.5",
44
+ "ink": "^5.0.0",
45
+ "ink-text-input": "^6.0.0",
46
+ "openai": "^4.50.0",
47
+ "react": "^18.2.0",
48
+ "simple-git": "^3.25.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.19.19",
52
+ "@types/react": "^18.2.0",
53
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
54
+ "@typescript-eslint/parser": "^7.0.0",
55
+ "eslint": "^8.57.0",
56
+ "tsx": "^4.15.0",
57
+ "typescript": "^5.5.0",
58
+ "vitest": "^1.6.0"
59
+ }
60
+ }