brownian-code 2026.2.10
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/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/brownian +25 -0
- package/env.example +21 -0
- package/package.json +87 -0
- package/src/agent/agent.test.ts +414 -0
- package/src/agent/agent.ts +385 -0
- package/src/agent/index.ts +27 -0
- package/src/agent/prompts.ts +271 -0
- package/src/agent/scratchpad.test.ts +482 -0
- package/src/agent/scratchpad.ts +526 -0
- package/src/agent/token-counter.test.ts +59 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/types.ts +137 -0
- package/src/cli.tsx +385 -0
- package/src/commands/builtin.test.ts +271 -0
- package/src/commands/builtin.ts +200 -0
- package/src/commands/registry.test.ts +188 -0
- package/src/commands/registry.ts +111 -0
- package/src/commands/types.ts +64 -0
- package/src/components/AgentEventView.tsx +487 -0
- package/src/components/AnswerBox.tsx +81 -0
- package/src/components/ApiKeyPrompt.tsx +75 -0
- package/src/components/CommandMenu.test.tsx +64 -0
- package/src/components/CommandMenu.tsx +38 -0
- package/src/components/CursorText.tsx +43 -0
- package/src/components/DebugPanel.tsx +48 -0
- package/src/components/ErrorBox.test.tsx +58 -0
- package/src/components/ErrorBox.tsx +26 -0
- package/src/components/HelpView.test.tsx +70 -0
- package/src/components/HelpView.tsx +61 -0
- package/src/components/HistoryItemView.tsx +108 -0
- package/src/components/Input.tsx +193 -0
- package/src/components/Intro.test.tsx +59 -0
- package/src/components/Intro.tsx +35 -0
- package/src/components/ModelSelector.tsx +288 -0
- package/src/components/StatusBar.test.tsx +78 -0
- package/src/components/StatusBar.tsx +56 -0
- package/src/components/WorkingIndicator.tsx +133 -0
- package/src/components/index.ts +23 -0
- package/src/e2e/agent-flow.test.ts +378 -0
- package/src/evals/components/EvalApp.tsx +206 -0
- package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
- package/src/evals/components/EvalProgress.tsx +33 -0
- package/src/evals/components/EvalRecentResults.tsx +63 -0
- package/src/evals/components/EvalStats.tsx +49 -0
- package/src/evals/components/index.ts +5 -0
- package/src/evals/dataset/crypto_agent.csv +16 -0
- package/src/evals/run.ts +355 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
- package/src/gateway/channels/whatsapp/inbound.ts +86 -0
- package/src/gateway/channels/whatsapp/login.ts +28 -0
- package/src/gateway/channels/whatsapp/outbound.ts +27 -0
- package/src/gateway/channels/whatsapp/session.ts +69 -0
- package/src/gateway/config.ts +81 -0
- package/src/gateway/index.ts +62 -0
- package/src/hooks/useAgentRunner.ts +317 -0
- package/src/hooks/useDebugLogs.ts +22 -0
- package/src/hooks/useInputHistory.ts +106 -0
- package/src/hooks/useModelSelection.ts +249 -0
- package/src/hooks/useTextBuffer.test.ts +121 -0
- package/src/hooks/useTextBuffer.ts +97 -0
- package/src/index.tsx +74 -0
- package/src/mcp/cache.ts +205 -0
- package/src/mcp/client.test.ts +126 -0
- package/src/mcp/client.ts +145 -0
- package/src/mcp/index.ts +2 -0
- package/src/model/llm.test.ts +158 -0
- package/src/model/llm.ts +233 -0
- package/src/providers.ts +94 -0
- package/src/skills/index.ts +17 -0
- package/src/skills/loader.ts +73 -0
- package/src/skills/registry.ts +125 -0
- package/src/skills/types.ts +31 -0
- package/src/test-utils/mocks.ts +110 -0
- package/src/theme.ts +21 -0
- package/src/tools/browser/browser.ts +357 -0
- package/src/tools/browser/index.ts +1 -0
- package/src/tools/crypto/hive-tools.ts +171 -0
- package/src/tools/crypto/index.ts +1 -0
- package/src/tools/descriptions/browser.ts +105 -0
- package/src/tools/descriptions/crypto-search.ts +58 -0
- package/src/tools/descriptions/index.ts +8 -0
- package/src/tools/descriptions/web-fetch.ts +44 -0
- package/src/tools/descriptions/web-search.ts +26 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +371 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/registry.ts +130 -0
- package/src/tools/search/exa.ts +43 -0
- package/src/tools/search/index.ts +2 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/skill.ts +62 -0
- package/src/tools/types.ts +53 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/config.ts +54 -0
- package/src/utils/cost-calculator.test.ts +101 -0
- package/src/utils/cost-calculator.ts +74 -0
- package/src/utils/env.ts +101 -0
- package/src/utils/error-classifier.test.ts +146 -0
- package/src/utils/error-classifier.ts +91 -0
- package/src/utils/in-memory-chat-history.test.ts +291 -0
- package/src/utils/in-memory-chat-history.ts +224 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/input-key-handlers.test.ts +155 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/text-navigation.test.ts +222 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +29 -0
- package/src/utils/tokens.test.ts +163 -0
- package/src/utils/tokens.ts +67 -0
- package/src/utils/tool-description.ts +88 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
|
|
4
|
+
import { colors } from '../theme.js';
|
|
5
|
+
import { useTextBuffer } from '../hooks/useTextBuffer.js';
|
|
6
|
+
import { cursorHandlers } from '../utils/input-key-handlers.js';
|
|
7
|
+
import { CursorText } from './CursorText.js';
|
|
8
|
+
|
|
9
|
+
interface InputProps {
|
|
10
|
+
onSubmit: (value: string) => void;
|
|
11
|
+
/** Value from history navigation (null = user typing fresh input) */
|
|
12
|
+
historyValue?: string | null;
|
|
13
|
+
/** Callback when user presses up/down arrow for history navigation */
|
|
14
|
+
onHistoryNavigate?: (direction: 'up' | 'down') => void;
|
|
15
|
+
/** Called on every text change (for slash command autocomplete) */
|
|
16
|
+
onTextChange?: (text: string) => void;
|
|
17
|
+
/** Whether the command autocomplete menu is visible */
|
|
18
|
+
commandMenuVisible?: boolean;
|
|
19
|
+
/** Navigate the command menu (up/down) */
|
|
20
|
+
onCommandMenuNavigate?: (direction: 'up' | 'down') => void;
|
|
21
|
+
/** Get the currently selected command from the menu */
|
|
22
|
+
getSelectedCommand?: () => string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Input({
|
|
26
|
+
onSubmit,
|
|
27
|
+
historyValue,
|
|
28
|
+
onHistoryNavigate,
|
|
29
|
+
onTextChange,
|
|
30
|
+
commandMenuVisible,
|
|
31
|
+
onCommandMenuNavigate,
|
|
32
|
+
getSelectedCommand,
|
|
33
|
+
}: InputProps) {
|
|
34
|
+
const { text, cursorPosition, actions } = useTextBuffer();
|
|
35
|
+
|
|
36
|
+
// Update input buffer when history navigation changes
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (historyValue === null) {
|
|
39
|
+
// Returned to typing mode - clear input for fresh entry
|
|
40
|
+
actions.clear();
|
|
41
|
+
} else if (historyValue !== undefined) {
|
|
42
|
+
// Navigating history - show the historical message
|
|
43
|
+
actions.setValue(historyValue);
|
|
44
|
+
}
|
|
45
|
+
}, [historyValue]);
|
|
46
|
+
|
|
47
|
+
// Notify parent of text changes
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
onTextChange?.(text);
|
|
50
|
+
}, [text]);
|
|
51
|
+
|
|
52
|
+
// Handle all input
|
|
53
|
+
useInput((input, key) => {
|
|
54
|
+
const ctx = { text, cursorPosition };
|
|
55
|
+
|
|
56
|
+
// Up arrow: if command menu is visible, navigate menu; else cursor/history
|
|
57
|
+
if (key.upArrow) {
|
|
58
|
+
if (commandMenuVisible && onCommandMenuNavigate) {
|
|
59
|
+
onCommandMenuNavigate('up');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const newPos = cursorHandlers.moveUp(ctx);
|
|
63
|
+
if (newPos !== null) {
|
|
64
|
+
actions.moveCursor(newPos);
|
|
65
|
+
} else if (onHistoryNavigate) {
|
|
66
|
+
onHistoryNavigate('up');
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Down arrow: if command menu is visible, navigate menu; else cursor/history
|
|
72
|
+
if (key.downArrow) {
|
|
73
|
+
if (commandMenuVisible && onCommandMenuNavigate) {
|
|
74
|
+
onCommandMenuNavigate('down');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const newPos = cursorHandlers.moveDown(ctx);
|
|
78
|
+
if (newPos !== null) {
|
|
79
|
+
actions.moveCursor(newPos);
|
|
80
|
+
} else if (onHistoryNavigate) {
|
|
81
|
+
onHistoryNavigate('down');
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Cursor movement - left arrow (plain, no modifiers)
|
|
87
|
+
if (key.leftArrow && !key.ctrl && !key.meta) {
|
|
88
|
+
actions.moveCursor(cursorHandlers.moveLeft(ctx));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Cursor movement - right arrow (plain, no modifiers)
|
|
93
|
+
if (key.rightArrow && !key.ctrl && !key.meta) {
|
|
94
|
+
actions.moveCursor(cursorHandlers.moveRight(ctx));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Ctrl+A - move to beginning of current line
|
|
99
|
+
if (key.ctrl && input === 'a') {
|
|
100
|
+
actions.moveCursor(cursorHandlers.moveToLineStart(ctx));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ctrl+E - move to end of current line
|
|
105
|
+
if (key.ctrl && input === 'e') {
|
|
106
|
+
actions.moveCursor(cursorHandlers.moveToLineEnd(ctx));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Option+Left (Mac) / Ctrl+Left (Windows) / Alt+B - word backward
|
|
111
|
+
if ((key.meta && key.leftArrow) || (key.ctrl && key.leftArrow) || (key.meta && input === 'b')) {
|
|
112
|
+
actions.moveCursor(cursorHandlers.moveWordBackward(ctx));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Option+Right (Mac) / Ctrl+Right (Windows) / Alt+F - word forward
|
|
117
|
+
if ((key.meta && key.rightArrow) || (key.ctrl && key.rightArrow) || (key.meta && input === 'f')) {
|
|
118
|
+
actions.moveCursor(cursorHandlers.moveWordForward(ctx));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Option+Backspace (Mac) / Ctrl+Backspace (Windows) - delete word backward
|
|
123
|
+
if ((key.meta || key.ctrl) && (key.backspace || key.delete)) {
|
|
124
|
+
actions.deleteWordBackward();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle backspace/delete - delete character before cursor
|
|
129
|
+
if (key.backspace || key.delete) {
|
|
130
|
+
actions.deleteBackward();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Shift+Enter - insert newline for multi-line input
|
|
135
|
+
if (key.return && key.shift) {
|
|
136
|
+
actions.insert('\n');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Tab - insert selected command from menu (if visible)
|
|
141
|
+
if (key.tab && commandMenuVisible && getSelectedCommand) {
|
|
142
|
+
const selected = getSelectedCommand();
|
|
143
|
+
if (selected) {
|
|
144
|
+
actions.setValue(selected + ' ', true);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Handle submit (plain Enter)
|
|
150
|
+
if (key.return) {
|
|
151
|
+
// If command menu is visible, insert selected command and execute
|
|
152
|
+
if (commandMenuVisible && getSelectedCommand) {
|
|
153
|
+
const selected = getSelectedCommand();
|
|
154
|
+
if (selected) {
|
|
155
|
+
actions.clear();
|
|
156
|
+
onSubmit(selected);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const val = text.trim();
|
|
162
|
+
if (val) {
|
|
163
|
+
onSubmit(val);
|
|
164
|
+
actions.clear();
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Handle regular character input - insert at cursor position
|
|
170
|
+
if (input && !key.ctrl && !key.meta) {
|
|
171
|
+
actions.insert(input);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Box
|
|
177
|
+
flexDirection="column"
|
|
178
|
+
marginBottom={1}
|
|
179
|
+
borderStyle="single"
|
|
180
|
+
borderColor={colors.mutedDark}
|
|
181
|
+
borderLeft={false}
|
|
182
|
+
borderRight={false}
|
|
183
|
+
width="100%"
|
|
184
|
+
>
|
|
185
|
+
<Box paddingX={1}>
|
|
186
|
+
<Text color={colors.primary} bold>
|
|
187
|
+
{'> '}
|
|
188
|
+
</Text>
|
|
189
|
+
<CursorText text={text} cursorPosition={cursorPosition} />
|
|
190
|
+
</Box>
|
|
191
|
+
</Box>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, cleanup } from 'ink-testing-library';
|
|
4
|
+
import { Intro } from './Intro.js';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Intro', () => {
|
|
9
|
+
test('renders Brownian Code title', () => {
|
|
10
|
+
const { lastFrame } = render(
|
|
11
|
+
React.createElement(Intro, { provider: 'anthropic', model: 'claude-sonnet-4-5' })
|
|
12
|
+
);
|
|
13
|
+
const frame = lastFrame()!;
|
|
14
|
+
expect(frame).toContain('Brownian Code');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('renders version number', () => {
|
|
18
|
+
const { lastFrame } = render(
|
|
19
|
+
React.createElement(Intro, { provider: 'anthropic', model: 'claude-sonnet-4-5' })
|
|
20
|
+
);
|
|
21
|
+
const frame = lastFrame()!;
|
|
22
|
+
expect(frame).toContain('v2026');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('renders crypto research description', () => {
|
|
26
|
+
const { lastFrame } = render(
|
|
27
|
+
React.createElement(Intro, { provider: 'anthropic', model: 'claude-sonnet-4-5' })
|
|
28
|
+
);
|
|
29
|
+
const frame = lastFrame()!;
|
|
30
|
+
expect(frame).toContain('crypto research');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('shows model display name', () => {
|
|
34
|
+
const { lastFrame } = render(
|
|
35
|
+
React.createElement(Intro, { provider: 'anthropic', model: 'claude-sonnet-4-5' })
|
|
36
|
+
);
|
|
37
|
+
const frame = lastFrame()!;
|
|
38
|
+
expect(frame).toContain('Model:');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('shows /model hint', () => {
|
|
42
|
+
const { lastFrame } = render(
|
|
43
|
+
React.createElement(Intro, { provider: 'anthropic', model: 'claude-sonnet-4-5' })
|
|
44
|
+
);
|
|
45
|
+
const frame = lastFrame()!;
|
|
46
|
+
expect(frame).toContain('Type /model to change');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('contains figlet ASCII art banner', () => {
|
|
50
|
+
const { lastFrame } = render(
|
|
51
|
+
React.createElement(Intro, { provider: 'anthropic', model: 'claude-sonnet-4-5' })
|
|
52
|
+
);
|
|
53
|
+
const frame = lastFrame()!;
|
|
54
|
+
expect(frame).toContain('____');
|
|
55
|
+
expect(frame).toContain('/ __');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import figlet from 'figlet';
|
|
4
|
+
import { colors } from '../theme.js';
|
|
5
|
+
import packageJson from '../../package.json';
|
|
6
|
+
import { getModelDisplayName } from './ModelSelector.js';
|
|
7
|
+
|
|
8
|
+
interface IntroProps {
|
|
9
|
+
provider: string;
|
|
10
|
+
model: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BANNER_LINES = figlet
|
|
14
|
+
.textSync('BROWNIAN', { font: 'Slant' as figlet.Fonts })
|
|
15
|
+
.split('\n')
|
|
16
|
+
.filter(l => l.trimEnd().length > 0);
|
|
17
|
+
|
|
18
|
+
export function Intro({ provider, model }: IntroProps) {
|
|
19
|
+
return (
|
|
20
|
+
<Box flexDirection="column" marginTop={1}>
|
|
21
|
+
{BANNER_LINES.map((line, i) => (
|
|
22
|
+
<Text key={i} color={colors.primary} bold>
|
|
23
|
+
{line}
|
|
24
|
+
</Text>
|
|
25
|
+
))}
|
|
26
|
+
<Box marginY={1} flexDirection="column">
|
|
27
|
+
<Text color={colors.muted}>Brownian Code <Text dimColor>v{packageJson.version}</Text></Text>
|
|
28
|
+
<Text>Your AI agent for crypto research.</Text>
|
|
29
|
+
<Text color={colors.muted}>
|
|
30
|
+
Model: <Text color={colors.primary}>{getModelDisplayName(model)}.</Text> Type /model to change.
|
|
31
|
+
</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
</Box>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { colors } from '../theme.js';
|
|
4
|
+
import { PROVIDERS as PROVIDER_DEFS } from '@/providers';
|
|
5
|
+
|
|
6
|
+
export interface Model {
|
|
7
|
+
id: string; // API model identifier (e.g., "claude-opus-4-6")
|
|
8
|
+
displayName: string; // Human-readable name (e.g., "Opus 4.6")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Provider {
|
|
12
|
+
displayName: string;
|
|
13
|
+
providerId: string;
|
|
14
|
+
models: Model[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// UI-specific model lists per provider (presentation data only)
|
|
18
|
+
const PROVIDER_MODELS: Record<string, Model[]> = {
|
|
19
|
+
openai: [
|
|
20
|
+
{ id: 'gpt-5.2', displayName: 'GPT 5.2' },
|
|
21
|
+
{ id: 'gpt-4.1', displayName: 'GPT 4.1' },
|
|
22
|
+
],
|
|
23
|
+
anthropic: [
|
|
24
|
+
{ id: 'claude-sonnet-4-5', displayName: 'Sonnet 4.5' },
|
|
25
|
+
{ id: 'claude-opus-4-6', displayName: 'Opus 4.6' },
|
|
26
|
+
],
|
|
27
|
+
google: [
|
|
28
|
+
{ id: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash' },
|
|
29
|
+
{ id: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro' },
|
|
30
|
+
],
|
|
31
|
+
xai: [
|
|
32
|
+
{ id: 'grok-4-0709', displayName: 'Grok 4' },
|
|
33
|
+
{ id: 'grok-4-1-fast-reasoning', displayName: 'Grok 4.1 Fast Reasoning' },
|
|
34
|
+
],
|
|
35
|
+
moonshot: [
|
|
36
|
+
{ id: 'kimi-k2-5', displayName: 'Kimi K2.5' },
|
|
37
|
+
],
|
|
38
|
+
deepseek: [
|
|
39
|
+
{ id: 'deepseek-chat', displayName: 'DeepSeek V3' },
|
|
40
|
+
{ id: 'deepseek-reasoner', displayName: 'DeepSeek R1' },
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Derive the provider list from the canonical registry, attaching local model lists
|
|
45
|
+
const PROVIDERS: Provider[] = PROVIDER_DEFS.map((p) => ({
|
|
46
|
+
displayName: p.displayName,
|
|
47
|
+
providerId: p.id,
|
|
48
|
+
models: PROVIDER_MODELS[p.id] ?? [],
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
export function getModelsForProvider(providerId: string): Model[] {
|
|
52
|
+
const provider = PROVIDERS.find((p) => p.providerId === providerId);
|
|
53
|
+
return provider?.models ?? [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getModelIdsForProvider(providerId: string): string[] {
|
|
57
|
+
return getModelsForProvider(providerId).map((m) => m.id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getDefaultModelForProvider(providerId: string): string | undefined {
|
|
61
|
+
const models = getModelsForProvider(providerId);
|
|
62
|
+
return models[0]?.id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getModelDisplayName(modelId: string): string {
|
|
66
|
+
// Handle prefixed model IDs (e.g., "ollama:llama3", "openrouter:anthropic/claude-3.5")
|
|
67
|
+
const normalizedId = modelId.replace(/^(ollama|openrouter):/, '');
|
|
68
|
+
|
|
69
|
+
// Search through all providers for the model
|
|
70
|
+
for (const provider of PROVIDERS) {
|
|
71
|
+
const model = provider.models.find((m) => m.id === normalizedId || m.id === modelId);
|
|
72
|
+
if (model) {
|
|
73
|
+
return model.displayName;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fallback: return the model ID as-is (for dynamic models like Ollama or OpenRouter)
|
|
78
|
+
return normalizedId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ProviderSelectorProps {
|
|
82
|
+
provider?: string;
|
|
83
|
+
onSelect: (providerId: string | null) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ProviderSelector({ provider, onSelect }: ProviderSelectorProps) {
|
|
87
|
+
const [selectedIndex, setSelectedIndex] = useState(() => {
|
|
88
|
+
if (provider) {
|
|
89
|
+
const idx = PROVIDERS.findIndex((p) => p.providerId === provider);
|
|
90
|
+
return idx >= 0 ? idx : 0;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
useInput((input, key) => {
|
|
96
|
+
if (key.upArrow || input === 'k') {
|
|
97
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
98
|
+
} else if (key.downArrow || input === 'j') {
|
|
99
|
+
setSelectedIndex((prev) => Math.min(PROVIDERS.length - 1, prev + 1));
|
|
100
|
+
} else if (key.return) {
|
|
101
|
+
onSelect(PROVIDERS[selectedIndex].providerId);
|
|
102
|
+
} else if (key.escape) {
|
|
103
|
+
onSelect(null);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Box flexDirection="column" marginTop={1}>
|
|
109
|
+
<Text color={colors.primary} bold>
|
|
110
|
+
Select provider
|
|
111
|
+
</Text>
|
|
112
|
+
<Text color={colors.muted}>
|
|
113
|
+
Switch between LLM providers. Applies to this session and future sessions.
|
|
114
|
+
</Text>
|
|
115
|
+
<Box marginTop={1} flexDirection="column">
|
|
116
|
+
{PROVIDERS.map((p, idx) => {
|
|
117
|
+
const isSelected = idx === selectedIndex;
|
|
118
|
+
const isCurrent = provider === p.providerId;
|
|
119
|
+
const prefix = isSelected ? '> ' : ' ';
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Text
|
|
123
|
+
key={p.providerId}
|
|
124
|
+
color={isSelected ? colors.primaryLight : colors.primary}
|
|
125
|
+
bold={isSelected}
|
|
126
|
+
>
|
|
127
|
+
{prefix}
|
|
128
|
+
{idx + 1}. {p.displayName}
|
|
129
|
+
{isCurrent ? ' ✓' : ''}
|
|
130
|
+
</Text>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</Box>
|
|
134
|
+
<Box marginTop={1}>
|
|
135
|
+
<Text color={colors.muted}>Enter to confirm · esc to exit</Text>
|
|
136
|
+
</Box>
|
|
137
|
+
</Box>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface ModelSelectorProps {
|
|
142
|
+
providerId: string;
|
|
143
|
+
models: Model[];
|
|
144
|
+
currentModel?: string;
|
|
145
|
+
onSelect: (modelId: string | null) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface ModelInputFieldProps {
|
|
149
|
+
providerId: string;
|
|
150
|
+
currentModel?: string;
|
|
151
|
+
onSubmit: (modelId: string | null) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function ModelInputField({ providerId, currentModel, onSubmit }: ModelInputFieldProps) {
|
|
155
|
+
// Extract existing model name if it has the openrouter: prefix
|
|
156
|
+
const initialValue = currentModel?.startsWith('openrouter:')
|
|
157
|
+
? currentModel.replace(/^openrouter:/, '')
|
|
158
|
+
: '';
|
|
159
|
+
|
|
160
|
+
const [inputValue, setInputValue] = useState(initialValue);
|
|
161
|
+
|
|
162
|
+
const provider = PROVIDERS.find((p) => p.providerId === providerId);
|
|
163
|
+
const providerName = provider?.displayName ?? providerId;
|
|
164
|
+
|
|
165
|
+
useInput((input, key) => {
|
|
166
|
+
if (key.return) {
|
|
167
|
+
const trimmed = inputValue.trim();
|
|
168
|
+
if (trimmed) {
|
|
169
|
+
onSubmit(trimmed);
|
|
170
|
+
}
|
|
171
|
+
} else if (key.escape) {
|
|
172
|
+
onSubmit(null);
|
|
173
|
+
} else if (key.backspace || key.delete) {
|
|
174
|
+
setInputValue((prev) => prev.slice(0, -1));
|
|
175
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
176
|
+
setInputValue((prev) => prev + input);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Box flexDirection="column" marginTop={1}>
|
|
182
|
+
<Text color={colors.primary} bold>
|
|
183
|
+
Enter model name for {providerName}
|
|
184
|
+
</Text>
|
|
185
|
+
<Text color={colors.muted}>
|
|
186
|
+
Type or paste the model name from openrouter.ai/models
|
|
187
|
+
</Text>
|
|
188
|
+
<Box marginTop={1}>
|
|
189
|
+
<Text color={colors.primaryLight}>{'> '}</Text>
|
|
190
|
+
<Text color={colors.primary}>{inputValue}</Text>
|
|
191
|
+
<Text color={colors.primaryLight}>_</Text>
|
|
192
|
+
</Box>
|
|
193
|
+
<Box marginTop={1} flexDirection="column">
|
|
194
|
+
<Text color={colors.muted}>
|
|
195
|
+
Examples: anthropic/claude-3.5-sonnet, openai/gpt-4-turbo, meta-llama/llama-3-70b
|
|
196
|
+
</Text>
|
|
197
|
+
</Box>
|
|
198
|
+
<Box marginTop={1}>
|
|
199
|
+
<Text color={colors.muted}>Enter to confirm · esc to go back</Text>
|
|
200
|
+
</Box>
|
|
201
|
+
</Box>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function ModelSelector({ providerId, models, currentModel, onSelect }: ModelSelectorProps) {
|
|
206
|
+
// For Ollama, the currentModel is stored with "ollama:" prefix, but models list doesn't have it
|
|
207
|
+
const normalizedCurrentModel = providerId === 'ollama' && currentModel?.startsWith('ollama:')
|
|
208
|
+
? currentModel.replace(/^ollama:/, '')
|
|
209
|
+
: currentModel;
|
|
210
|
+
|
|
211
|
+
const [selectedIndex, setSelectedIndex] = useState(() => {
|
|
212
|
+
if (normalizedCurrentModel) {
|
|
213
|
+
const idx = models.findIndex((m) => m.id === normalizedCurrentModel);
|
|
214
|
+
return idx >= 0 ? idx : 0;
|
|
215
|
+
}
|
|
216
|
+
return 0;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const provider = PROVIDERS.find((p) => p.providerId === providerId);
|
|
220
|
+
const providerName = provider?.displayName ?? providerId;
|
|
221
|
+
|
|
222
|
+
useInput((input, key) => {
|
|
223
|
+
if (key.upArrow || input === 'k') {
|
|
224
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
225
|
+
} else if (key.downArrow || input === 'j') {
|
|
226
|
+
setSelectedIndex((prev) => Math.min(models.length - 1, prev + 1));
|
|
227
|
+
} else if (key.return) {
|
|
228
|
+
if (models.length > 0) {
|
|
229
|
+
onSelect(models[selectedIndex].id);
|
|
230
|
+
}
|
|
231
|
+
} else if (key.escape) {
|
|
232
|
+
onSelect(null);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (models.length === 0) {
|
|
237
|
+
return (
|
|
238
|
+
<Box flexDirection="column" marginTop={1}>
|
|
239
|
+
<Text color={colors.primary} bold>
|
|
240
|
+
Select model for {providerName}
|
|
241
|
+
</Text>
|
|
242
|
+
<Box marginTop={1}>
|
|
243
|
+
<Text color={colors.muted}>No models available. </Text>
|
|
244
|
+
{providerId === 'ollama' && (
|
|
245
|
+
<Text color={colors.muted}>
|
|
246
|
+
Make sure Ollama is running and you have models downloaded.
|
|
247
|
+
</Text>
|
|
248
|
+
)}
|
|
249
|
+
</Box>
|
|
250
|
+
<Box marginTop={1}>
|
|
251
|
+
<Text color={colors.muted}>esc to go back</Text>
|
|
252
|
+
</Box>
|
|
253
|
+
</Box>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<Box flexDirection="column" marginTop={1}>
|
|
259
|
+
<Text color={colors.primary} bold>
|
|
260
|
+
Select model for {providerName}
|
|
261
|
+
</Text>
|
|
262
|
+
<Box marginTop={1} flexDirection="column">
|
|
263
|
+
{models.map((model, idx) => {
|
|
264
|
+
const isSelected = idx === selectedIndex;
|
|
265
|
+
const isCurrent = normalizedCurrentModel === model.id;
|
|
266
|
+
const prefix = isSelected ? '> ' : ' ';
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<Text
|
|
270
|
+
key={model.id}
|
|
271
|
+
color={isSelected ? colors.primaryLight : colors.primary}
|
|
272
|
+
bold={isSelected}
|
|
273
|
+
>
|
|
274
|
+
{prefix}
|
|
275
|
+
{idx + 1}. {model.displayName}
|
|
276
|
+
{isCurrent ? ' ✓' : ''}
|
|
277
|
+
</Text>
|
|
278
|
+
);
|
|
279
|
+
})}
|
|
280
|
+
</Box>
|
|
281
|
+
<Box marginTop={1}>
|
|
282
|
+
<Text color={colors.muted}>Enter to confirm · esc to go back</Text>
|
|
283
|
+
</Box>
|
|
284
|
+
</Box>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { PROVIDERS };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, cleanup } from 'ink-testing-library';
|
|
4
|
+
import { StatusBar } from './StatusBar.js';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('StatusBar', () => {
|
|
9
|
+
test('renders nothing when turnCount is 0', () => {
|
|
10
|
+
const { lastFrame } = render(
|
|
11
|
+
React.createElement(StatusBar, {
|
|
12
|
+
modelDisplayName: 'Sonnet 4.5',
|
|
13
|
+
cumulativeTokens: 0,
|
|
14
|
+
cumulativeCost: 0,
|
|
15
|
+
turnCount: 0,
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
expect(lastFrame()).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('renders model name, tokens, cost, and turn count', () => {
|
|
22
|
+
const { lastFrame } = render(
|
|
23
|
+
React.createElement(StatusBar, {
|
|
24
|
+
modelDisplayName: 'Sonnet 4.5',
|
|
25
|
+
cumulativeTokens: 1234,
|
|
26
|
+
cumulativeCost: 0.0042,
|
|
27
|
+
turnCount: 3,
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
const frame = lastFrame()!;
|
|
31
|
+
expect(frame).toContain('Sonnet 4.5');
|
|
32
|
+
expect(frame).toContain('1,234 tokens');
|
|
33
|
+
expect(frame).toContain('$0.0042');
|
|
34
|
+
expect(frame).toContain('3 turns');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('uses singular "turn" for turnCount 1', () => {
|
|
38
|
+
const { lastFrame } = render(
|
|
39
|
+
React.createElement(StatusBar, {
|
|
40
|
+
modelDisplayName: 'GPT-5.2',
|
|
41
|
+
cumulativeTokens: 500,
|
|
42
|
+
cumulativeCost: 0.001,
|
|
43
|
+
turnCount: 1,
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
const frame = lastFrame()!;
|
|
47
|
+
expect(frame).toContain('1 turn');
|
|
48
|
+
expect(frame).not.toContain('1 turns');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('shows context warning at >90%', () => {
|
|
52
|
+
const { lastFrame } = render(
|
|
53
|
+
React.createElement(StatusBar, {
|
|
54
|
+
modelDisplayName: 'Sonnet 4.5',
|
|
55
|
+
cumulativeTokens: 100000,
|
|
56
|
+
cumulativeCost: 1.5,
|
|
57
|
+
turnCount: 10,
|
|
58
|
+
contextPercentage: 95,
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
const frame = lastFrame()!;
|
|
62
|
+
expect(frame).toContain('/compact');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('does not show context warning at 50%', () => {
|
|
66
|
+
const { lastFrame } = render(
|
|
67
|
+
React.createElement(StatusBar, {
|
|
68
|
+
modelDisplayName: 'Sonnet 4.5',
|
|
69
|
+
cumulativeTokens: 5000,
|
|
70
|
+
cumulativeCost: 0.05,
|
|
71
|
+
turnCount: 2,
|
|
72
|
+
contextPercentage: 50,
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
const frame = lastFrame()!;
|
|
76
|
+
expect(frame).not.toContain('/compact');
|
|
77
|
+
});
|
|
78
|
+
});
|