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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/bin/brownian +25 -0
  4. package/env.example +21 -0
  5. package/package.json +87 -0
  6. package/src/agent/agent.test.ts +414 -0
  7. package/src/agent/agent.ts +385 -0
  8. package/src/agent/index.ts +27 -0
  9. package/src/agent/prompts.ts +271 -0
  10. package/src/agent/scratchpad.test.ts +482 -0
  11. package/src/agent/scratchpad.ts +526 -0
  12. package/src/agent/token-counter.test.ts +59 -0
  13. package/src/agent/token-counter.ts +33 -0
  14. package/src/agent/types.ts +137 -0
  15. package/src/cli.tsx +385 -0
  16. package/src/commands/builtin.test.ts +271 -0
  17. package/src/commands/builtin.ts +200 -0
  18. package/src/commands/registry.test.ts +188 -0
  19. package/src/commands/registry.ts +111 -0
  20. package/src/commands/types.ts +64 -0
  21. package/src/components/AgentEventView.tsx +487 -0
  22. package/src/components/AnswerBox.tsx +81 -0
  23. package/src/components/ApiKeyPrompt.tsx +75 -0
  24. package/src/components/CommandMenu.test.tsx +64 -0
  25. package/src/components/CommandMenu.tsx +38 -0
  26. package/src/components/CursorText.tsx +43 -0
  27. package/src/components/DebugPanel.tsx +48 -0
  28. package/src/components/ErrorBox.test.tsx +58 -0
  29. package/src/components/ErrorBox.tsx +26 -0
  30. package/src/components/HelpView.test.tsx +70 -0
  31. package/src/components/HelpView.tsx +61 -0
  32. package/src/components/HistoryItemView.tsx +108 -0
  33. package/src/components/Input.tsx +193 -0
  34. package/src/components/Intro.test.tsx +59 -0
  35. package/src/components/Intro.tsx +35 -0
  36. package/src/components/ModelSelector.tsx +288 -0
  37. package/src/components/StatusBar.test.tsx +78 -0
  38. package/src/components/StatusBar.tsx +56 -0
  39. package/src/components/WorkingIndicator.tsx +133 -0
  40. package/src/components/index.ts +23 -0
  41. package/src/e2e/agent-flow.test.ts +378 -0
  42. package/src/evals/components/EvalApp.tsx +206 -0
  43. package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
  44. package/src/evals/components/EvalProgress.tsx +33 -0
  45. package/src/evals/components/EvalRecentResults.tsx +63 -0
  46. package/src/evals/components/EvalStats.tsx +49 -0
  47. package/src/evals/components/index.ts +5 -0
  48. package/src/evals/dataset/crypto_agent.csv +16 -0
  49. package/src/evals/run.ts +355 -0
  50. package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
  51. package/src/gateway/channels/whatsapp/inbound.ts +86 -0
  52. package/src/gateway/channels/whatsapp/login.ts +28 -0
  53. package/src/gateway/channels/whatsapp/outbound.ts +27 -0
  54. package/src/gateway/channels/whatsapp/session.ts +69 -0
  55. package/src/gateway/config.ts +81 -0
  56. package/src/gateway/index.ts +62 -0
  57. package/src/hooks/useAgentRunner.ts +317 -0
  58. package/src/hooks/useDebugLogs.ts +22 -0
  59. package/src/hooks/useInputHistory.ts +106 -0
  60. package/src/hooks/useModelSelection.ts +249 -0
  61. package/src/hooks/useTextBuffer.test.ts +121 -0
  62. package/src/hooks/useTextBuffer.ts +97 -0
  63. package/src/index.tsx +74 -0
  64. package/src/mcp/cache.ts +205 -0
  65. package/src/mcp/client.test.ts +126 -0
  66. package/src/mcp/client.ts +145 -0
  67. package/src/mcp/index.ts +2 -0
  68. package/src/model/llm.test.ts +158 -0
  69. package/src/model/llm.ts +233 -0
  70. package/src/providers.ts +94 -0
  71. package/src/skills/index.ts +17 -0
  72. package/src/skills/loader.ts +73 -0
  73. package/src/skills/registry.ts +125 -0
  74. package/src/skills/types.ts +31 -0
  75. package/src/test-utils/mocks.ts +110 -0
  76. package/src/theme.ts +21 -0
  77. package/src/tools/browser/browser.ts +357 -0
  78. package/src/tools/browser/index.ts +1 -0
  79. package/src/tools/crypto/hive-tools.ts +171 -0
  80. package/src/tools/crypto/index.ts +1 -0
  81. package/src/tools/descriptions/browser.ts +105 -0
  82. package/src/tools/descriptions/crypto-search.ts +58 -0
  83. package/src/tools/descriptions/index.ts +8 -0
  84. package/src/tools/descriptions/web-fetch.ts +44 -0
  85. package/src/tools/descriptions/web-search.ts +26 -0
  86. package/src/tools/fetch/cache.ts +95 -0
  87. package/src/tools/fetch/external-content.ts +200 -0
  88. package/src/tools/fetch/index.ts +1 -0
  89. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  90. package/src/tools/fetch/web-fetch.ts +371 -0
  91. package/src/tools/index.ts +12 -0
  92. package/src/tools/registry.ts +130 -0
  93. package/src/tools/search/exa.ts +43 -0
  94. package/src/tools/search/index.ts +2 -0
  95. package/src/tools/search/tavily.ts +35 -0
  96. package/src/tools/skill.ts +62 -0
  97. package/src/tools/types.ts +53 -0
  98. package/src/utils/ai-message.ts +26 -0
  99. package/src/utils/config.ts +54 -0
  100. package/src/utils/cost-calculator.test.ts +101 -0
  101. package/src/utils/cost-calculator.ts +74 -0
  102. package/src/utils/env.ts +101 -0
  103. package/src/utils/error-classifier.test.ts +146 -0
  104. package/src/utils/error-classifier.ts +91 -0
  105. package/src/utils/in-memory-chat-history.test.ts +291 -0
  106. package/src/utils/in-memory-chat-history.ts +224 -0
  107. package/src/utils/index.ts +19 -0
  108. package/src/utils/input-key-handlers.test.ts +155 -0
  109. package/src/utils/input-key-handlers.ts +64 -0
  110. package/src/utils/logger.ts +67 -0
  111. package/src/utils/long-term-chat-history.ts +138 -0
  112. package/src/utils/markdown-table.ts +227 -0
  113. package/src/utils/ollama.ts +37 -0
  114. package/src/utils/progress-channel.ts +84 -0
  115. package/src/utils/text-navigation.test.ts +222 -0
  116. package/src/utils/text-navigation.ts +81 -0
  117. package/src/utils/thinking-verbs.ts +29 -0
  118. package/src/utils/tokens.test.ts +163 -0
  119. package/src/utils/tokens.ts +67 -0
  120. 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
+ });