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,249 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { getSetting, setSetting } from '../utils/config.js';
|
|
3
|
+
import { getProviderDisplayName, checkApiKeyExistsForProvider, saveApiKeyForProvider } from '../utils/env.js';
|
|
4
|
+
import { getModelsForProvider, getDefaultModelForProvider, type Model } from '../components/ModelSelector.js';
|
|
5
|
+
import { getOllamaModels } from '../utils/ollama.js';
|
|
6
|
+
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '../model/llm.js';
|
|
7
|
+
import { InMemoryChatHistory } from '../utils/in-memory-chat-history.js';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Types
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
const SELECTION_STATES = ['provider_select', 'model_select', 'model_input', 'api_key_confirm', 'api_key_input'] as const;
|
|
14
|
+
type SelectionState = typeof SELECTION_STATES[number];
|
|
15
|
+
type AppState = 'idle' | SelectionState;
|
|
16
|
+
|
|
17
|
+
export interface ModelSelectionState {
|
|
18
|
+
appState: AppState;
|
|
19
|
+
pendingProvider: string | null;
|
|
20
|
+
pendingModels: Model[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseModelSelectionResult {
|
|
24
|
+
// Current state
|
|
25
|
+
selectionState: ModelSelectionState;
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
inMemoryChatHistoryRef: React.RefObject<InMemoryChatHistory>;
|
|
29
|
+
|
|
30
|
+
// Actions
|
|
31
|
+
startSelection: () => void;
|
|
32
|
+
cancelSelection: () => void;
|
|
33
|
+
handleProviderSelect: (providerId: string | null) => Promise<void>;
|
|
34
|
+
handleModelSelect: (modelId: string | null) => void;
|
|
35
|
+
handleModelInputSubmit: (modelName: string | null) => void;
|
|
36
|
+
handleApiKeyConfirm: (wantsToSet: boolean) => void;
|
|
37
|
+
handleApiKeySubmit: (apiKey: string | null) => void;
|
|
38
|
+
|
|
39
|
+
// Helpers
|
|
40
|
+
isInSelectionFlow: () => boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Helper
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
function isSelectionState(state: AppState): state is SelectionState {
|
|
48
|
+
return (SELECTION_STATES as readonly string[]).includes(state);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Hook
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
export function useModelSelection(
|
|
56
|
+
onError: (message: string) => void
|
|
57
|
+
): UseModelSelectionResult {
|
|
58
|
+
// Provider and model state (persisted)
|
|
59
|
+
const [provider, setProvider] = useState(() => getSetting('provider', DEFAULT_PROVIDER));
|
|
60
|
+
const [model, setModel] = useState(() => {
|
|
61
|
+
const savedModel = getSetting('modelId', null) as string | null;
|
|
62
|
+
const savedProvider = getSetting('provider', DEFAULT_PROVIDER) as string;
|
|
63
|
+
if (savedModel) {
|
|
64
|
+
return savedModel;
|
|
65
|
+
}
|
|
66
|
+
return getDefaultModelForProvider(savedProvider) || DEFAULT_MODEL;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Selection flow state
|
|
70
|
+
const [appState, setAppState] = useState<AppState>('idle');
|
|
71
|
+
const [pendingProvider, setPendingProvider] = useState<string | null>(null);
|
|
72
|
+
const [pendingModels, setPendingModels] = useState<Model[]>([]);
|
|
73
|
+
const [pendingSelectedModelId, setPendingSelectedModelId] = useState<string | null>(null);
|
|
74
|
+
|
|
75
|
+
// Message history ref - shared with agent runner
|
|
76
|
+
const inMemoryChatHistoryRef = useRef<InMemoryChatHistory>(new InMemoryChatHistory(model));
|
|
77
|
+
|
|
78
|
+
// Helper to complete a model switch (DRY pattern)
|
|
79
|
+
const completeModelSwitch = useCallback((newProvider: string, newModelId: string) => {
|
|
80
|
+
setProvider(newProvider);
|
|
81
|
+
setModel(newModelId);
|
|
82
|
+
setSetting('provider', newProvider);
|
|
83
|
+
setSetting('modelId', newModelId);
|
|
84
|
+
inMemoryChatHistoryRef.current.setModel(newModelId);
|
|
85
|
+
setPendingProvider(null);
|
|
86
|
+
setPendingModels([]);
|
|
87
|
+
setPendingSelectedModelId(null);
|
|
88
|
+
setAppState('idle');
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Reset pending state
|
|
92
|
+
const resetPendingState = useCallback(() => {
|
|
93
|
+
setPendingProvider(null);
|
|
94
|
+
setPendingModels([]);
|
|
95
|
+
setPendingSelectedModelId(null);
|
|
96
|
+
setAppState('idle');
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// Start selection flow
|
|
100
|
+
const startSelection = useCallback(() => {
|
|
101
|
+
setAppState('provider_select');
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
// Cancel selection flow
|
|
105
|
+
const cancelSelection = useCallback(() => {
|
|
106
|
+
resetPendingState();
|
|
107
|
+
}, [resetPendingState]);
|
|
108
|
+
|
|
109
|
+
// Check if in selection flow
|
|
110
|
+
const isInSelectionFlow = useCallback(() => {
|
|
111
|
+
return isSelectionState(appState);
|
|
112
|
+
}, [appState]);
|
|
113
|
+
|
|
114
|
+
// Provider selection handler
|
|
115
|
+
const handleProviderSelect = useCallback(async (providerId: string | null) => {
|
|
116
|
+
if (providerId) {
|
|
117
|
+
setPendingProvider(providerId);
|
|
118
|
+
|
|
119
|
+
// OpenRouter uses free-text model input instead of a list
|
|
120
|
+
if (providerId === 'openrouter') {
|
|
121
|
+
setPendingModels([]);
|
|
122
|
+
setAppState('model_input');
|
|
123
|
+
} else if (providerId === 'ollama') {
|
|
124
|
+
// Fetch models from local Ollama API and convert to Model objects
|
|
125
|
+
const ollamaModelIds = await getOllamaModels();
|
|
126
|
+
const ollamaModels: Model[] = ollamaModelIds.map((id) => ({ id, displayName: id }));
|
|
127
|
+
setPendingModels(ollamaModels);
|
|
128
|
+
setAppState('model_select');
|
|
129
|
+
} else {
|
|
130
|
+
setPendingModels(getModelsForProvider(providerId));
|
|
131
|
+
setAppState('model_select');
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
setAppState('idle');
|
|
135
|
+
}
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
// Model selection handler (for list-based selection)
|
|
139
|
+
const handleModelSelect = useCallback((modelId: string | null) => {
|
|
140
|
+
if (!modelId || !pendingProvider) {
|
|
141
|
+
// User cancelled - go back to provider select
|
|
142
|
+
setPendingProvider(null);
|
|
143
|
+
setPendingModels([]);
|
|
144
|
+
setPendingSelectedModelId(null);
|
|
145
|
+
setAppState('provider_select');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// For Ollama, skip API key flow entirely
|
|
150
|
+
if (pendingProvider === 'ollama') {
|
|
151
|
+
const fullModelId = `ollama:${modelId}`;
|
|
152
|
+
completeModelSwitch(pendingProvider, fullModelId);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// For cloud providers, check API key
|
|
157
|
+
if (checkApiKeyExistsForProvider(pendingProvider)) {
|
|
158
|
+
completeModelSwitch(pendingProvider, modelId);
|
|
159
|
+
} else {
|
|
160
|
+
// Need to get API key - store the selected model temporarily
|
|
161
|
+
setPendingSelectedModelId(modelId);
|
|
162
|
+
setAppState('api_key_confirm');
|
|
163
|
+
}
|
|
164
|
+
}, [pendingProvider, completeModelSwitch]);
|
|
165
|
+
|
|
166
|
+
// Model input handler (for free-text input like OpenRouter)
|
|
167
|
+
const handleModelInputSubmit = useCallback((modelName: string | null) => {
|
|
168
|
+
if (!modelName || !pendingProvider) {
|
|
169
|
+
// User cancelled - go back to provider select
|
|
170
|
+
setPendingProvider(null);
|
|
171
|
+
setPendingModels([]);
|
|
172
|
+
setPendingSelectedModelId(null);
|
|
173
|
+
setAppState('provider_select');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Store with provider prefix (e.g., openrouter:anthropic/claude-3.5-sonnet)
|
|
178
|
+
const fullModelId = `${pendingProvider}:${modelName}`;
|
|
179
|
+
|
|
180
|
+
// Check API key for the provider
|
|
181
|
+
if (checkApiKeyExistsForProvider(pendingProvider)) {
|
|
182
|
+
completeModelSwitch(pendingProvider, fullModelId);
|
|
183
|
+
} else {
|
|
184
|
+
// Need to get API key - store the selected model temporarily
|
|
185
|
+
setPendingSelectedModelId(fullModelId);
|
|
186
|
+
setAppState('api_key_confirm');
|
|
187
|
+
}
|
|
188
|
+
}, [pendingProvider, completeModelSwitch]);
|
|
189
|
+
|
|
190
|
+
// API key confirmation handler
|
|
191
|
+
const handleApiKeyConfirm = useCallback((wantsToSet: boolean) => {
|
|
192
|
+
if (wantsToSet) {
|
|
193
|
+
setAppState('api_key_input');
|
|
194
|
+
} else {
|
|
195
|
+
// Check if existing key is available
|
|
196
|
+
if (pendingProvider && pendingSelectedModelId && checkApiKeyExistsForProvider(pendingProvider)) {
|
|
197
|
+
completeModelSwitch(pendingProvider, pendingSelectedModelId);
|
|
198
|
+
} else {
|
|
199
|
+
onError(`Cannot use ${pendingProvider ? getProviderDisplayName(pendingProvider) : 'provider'} without an API key.`);
|
|
200
|
+
resetPendingState();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}, [pendingProvider, pendingSelectedModelId, completeModelSwitch, resetPendingState, onError]);
|
|
204
|
+
|
|
205
|
+
// API key submit handler
|
|
206
|
+
const handleApiKeySubmit = useCallback((apiKey: string | null) => {
|
|
207
|
+
// Guard: ensure we have a selected model
|
|
208
|
+
if (!pendingSelectedModelId) {
|
|
209
|
+
onError('No model selected.');
|
|
210
|
+
resetPendingState();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (apiKey && pendingProvider) {
|
|
215
|
+
const saved = saveApiKeyForProvider(pendingProvider, apiKey);
|
|
216
|
+
if (saved) {
|
|
217
|
+
completeModelSwitch(pendingProvider, pendingSelectedModelId);
|
|
218
|
+
} else {
|
|
219
|
+
onError('Failed to save API key.');
|
|
220
|
+
resetPendingState();
|
|
221
|
+
}
|
|
222
|
+
} else if (!apiKey && pendingProvider && checkApiKeyExistsForProvider(pendingProvider)) {
|
|
223
|
+
// Cancelled but existing key available
|
|
224
|
+
completeModelSwitch(pendingProvider, pendingSelectedModelId);
|
|
225
|
+
} else {
|
|
226
|
+
onError('API key not set. Provider unchanged.');
|
|
227
|
+
resetPendingState();
|
|
228
|
+
}
|
|
229
|
+
}, [pendingProvider, pendingSelectedModelId, completeModelSwitch, resetPendingState, onError]);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
selectionState: {
|
|
233
|
+
appState,
|
|
234
|
+
pendingProvider,
|
|
235
|
+
pendingModels,
|
|
236
|
+
},
|
|
237
|
+
provider,
|
|
238
|
+
model,
|
|
239
|
+
inMemoryChatHistoryRef,
|
|
240
|
+
startSelection,
|
|
241
|
+
cancelSelection,
|
|
242
|
+
handleProviderSelect,
|
|
243
|
+
handleModelSelect,
|
|
244
|
+
handleModelInputSubmit,
|
|
245
|
+
handleApiKeyConfirm,
|
|
246
|
+
handleApiKeySubmit,
|
|
247
|
+
isInSelectionFlow,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
2
|
+
import React, { useState, useCallback } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { render, cleanup } from 'ink-testing-library';
|
|
5
|
+
import { useTextBuffer } from './useTextBuffer.js';
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Test component that drives useTextBuffer through stdin keypresses.
|
|
11
|
+
* Instead of calling actions directly (which don't trigger re-renders in test),
|
|
12
|
+
* we test via the same mechanism the real Input uses: useInput-like patterns.
|
|
13
|
+
*
|
|
14
|
+
* For unit testing of ref-based hook, we use a command-driven approach:
|
|
15
|
+
* The component reads a "command" prop and executes it on mount/update.
|
|
16
|
+
*/
|
|
17
|
+
function TestBuffer({ command }: { command: string }) {
|
|
18
|
+
const { text, cursorPosition, actions } = useTextBuffer();
|
|
19
|
+
|
|
20
|
+
// Execute command on each render with a new command value
|
|
21
|
+
const [lastCmd, setLastCmd] = React.useState('');
|
|
22
|
+
if (command !== lastCmd) {
|
|
23
|
+
setLastCmd(command);
|
|
24
|
+
const parts = command.split(':');
|
|
25
|
+
const op = parts[0];
|
|
26
|
+
const arg = parts.slice(1).join(':');
|
|
27
|
+
|
|
28
|
+
switch (op) {
|
|
29
|
+
case 'insert': actions.insert(arg); break;
|
|
30
|
+
case 'delete': actions.deleteBackward(); break;
|
|
31
|
+
case 'deleteWord': actions.deleteWordBackward(); break;
|
|
32
|
+
case 'move': actions.moveCursor(parseInt(arg)); break;
|
|
33
|
+
case 'clear': actions.clear(); break;
|
|
34
|
+
case 'set': actions.setValue(arg); break;
|
|
35
|
+
case 'setStart': actions.setValue(arg, false); break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return React.createElement(Text, null, `[${text}|${cursorPosition}]`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Since useTextBuffer uses refs + forceRender, we need to give each test
|
|
43
|
+
// a unique command string to trigger the state-in-render pattern.
|
|
44
|
+
// We'll use rerender() to drive state changes.
|
|
45
|
+
|
|
46
|
+
describe('useTextBuffer - insert', () => {
|
|
47
|
+
test('inserts text', () => {
|
|
48
|
+
const { lastFrame, rerender } = render(
|
|
49
|
+
React.createElement(TestBuffer, { command: '' })
|
|
50
|
+
);
|
|
51
|
+
rerender(React.createElement(TestBuffer, { command: 'insert:hello' }));
|
|
52
|
+
expect(lastFrame()).toBe('[hello|5]');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('inserts multi-character text', () => {
|
|
56
|
+
const { lastFrame, rerender } = render(
|
|
57
|
+
React.createElement(TestBuffer, { command: '' })
|
|
58
|
+
);
|
|
59
|
+
rerender(React.createElement(TestBuffer, { command: 'insert:abc' }));
|
|
60
|
+
expect(lastFrame()).toBe('[abc|3]');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('useTextBuffer - setValue', () => {
|
|
65
|
+
test('sets value with cursor at end', () => {
|
|
66
|
+
const { lastFrame, rerender } = render(
|
|
67
|
+
React.createElement(TestBuffer, { command: '' })
|
|
68
|
+
);
|
|
69
|
+
rerender(React.createElement(TestBuffer, { command: 'set:hello world' }));
|
|
70
|
+
expect(lastFrame()).toBe('[hello world|11]');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('sets value with cursor at start', () => {
|
|
74
|
+
const { lastFrame, rerender } = render(
|
|
75
|
+
React.createElement(TestBuffer, { command: '' })
|
|
76
|
+
);
|
|
77
|
+
rerender(React.createElement(TestBuffer, { command: 'setStart:hello world' }));
|
|
78
|
+
expect(lastFrame()).toBe('[hello world|0]');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('useTextBuffer - clear', () => {
|
|
83
|
+
test('clears text and resets cursor', () => {
|
|
84
|
+
const { lastFrame, rerender } = render(
|
|
85
|
+
React.createElement(TestBuffer, { command: '' })
|
|
86
|
+
);
|
|
87
|
+
rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
|
|
88
|
+
expect(lastFrame()).toBe('[hello|5]');
|
|
89
|
+
rerender(React.createElement(TestBuffer, { command: 'clear' }));
|
|
90
|
+
expect(lastFrame()).toBe('[|0]');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('useTextBuffer - moveCursor', () => {
|
|
95
|
+
test('moves to valid position', () => {
|
|
96
|
+
const { lastFrame, rerender } = render(
|
|
97
|
+
React.createElement(TestBuffer, { command: '' })
|
|
98
|
+
);
|
|
99
|
+
rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
|
|
100
|
+
rerender(React.createElement(TestBuffer, { command: 'move:2' }));
|
|
101
|
+
expect(lastFrame()).toBe('[hello|2]');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('clamps below 0', () => {
|
|
105
|
+
const { lastFrame, rerender } = render(
|
|
106
|
+
React.createElement(TestBuffer, { command: '' })
|
|
107
|
+
);
|
|
108
|
+
rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
|
|
109
|
+
rerender(React.createElement(TestBuffer, { command: 'move:-5' }));
|
|
110
|
+
expect(lastFrame()).toBe('[hello|0]');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('clamps above text length', () => {
|
|
114
|
+
const { lastFrame, rerender } = render(
|
|
115
|
+
React.createElement(TestBuffer, { command: '' })
|
|
116
|
+
);
|
|
117
|
+
rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
|
|
118
|
+
rerender(React.createElement(TestBuffer, { command: 'move:100' }));
|
|
119
|
+
expect(lastFrame()).toBe('[hello|5]');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { findPrevWordStart } from '../utils/text-navigation.js';
|
|
3
|
+
|
|
4
|
+
export interface TextBufferActions {
|
|
5
|
+
/** Insert text at the current cursor position */
|
|
6
|
+
insert: (text: string) => void;
|
|
7
|
+
/** Delete the character before the cursor */
|
|
8
|
+
deleteBackward: () => void;
|
|
9
|
+
/** Delete from cursor back to start of previous word */
|
|
10
|
+
deleteWordBackward: () => void;
|
|
11
|
+
/** Move cursor to an absolute position (clamped to valid range) */
|
|
12
|
+
moveCursor: (position: number) => void;
|
|
13
|
+
/** Clear the buffer and reset cursor to 0 */
|
|
14
|
+
clear: () => void;
|
|
15
|
+
/** Set the buffer value and optionally position cursor at end */
|
|
16
|
+
setValue: (value: string, cursorAtEnd?: boolean) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseTextBufferResult {
|
|
20
|
+
/** Current text content */
|
|
21
|
+
text: string;
|
|
22
|
+
/** Current cursor position (0-indexed) */
|
|
23
|
+
cursorPosition: number;
|
|
24
|
+
/** Actions to manipulate the buffer */
|
|
25
|
+
actions: TextBufferActions;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hook for managing a text buffer with cursor position.
|
|
30
|
+
* Uses refs internally to avoid React state race conditions with fast typing,
|
|
31
|
+
* while still triggering re-renders when content changes.
|
|
32
|
+
*/
|
|
33
|
+
export function useTextBuffer(): UseTextBufferResult {
|
|
34
|
+
// Use refs to avoid race conditions with fast typing
|
|
35
|
+
const buffer = useRef('');
|
|
36
|
+
const cursorPos = useRef(0);
|
|
37
|
+
const [, forceRender] = useState(0);
|
|
38
|
+
|
|
39
|
+
const rerender = useCallback(() => forceRender(x => x + 1), []);
|
|
40
|
+
|
|
41
|
+
const actions: TextBufferActions = {
|
|
42
|
+
insert: (input: string) => {
|
|
43
|
+
// Normalize line endings but preserve newlines for multi-line input
|
|
44
|
+
const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
45
|
+
buffer.current =
|
|
46
|
+
buffer.current.slice(0, cursorPos.current) +
|
|
47
|
+
normalized +
|
|
48
|
+
buffer.current.slice(cursorPos.current);
|
|
49
|
+
cursorPos.current += normalized.length;
|
|
50
|
+
rerender();
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
deleteBackward: () => {
|
|
54
|
+
if (cursorPos.current > 0) {
|
|
55
|
+
buffer.current =
|
|
56
|
+
buffer.current.slice(0, cursorPos.current - 1) +
|
|
57
|
+
buffer.current.slice(cursorPos.current);
|
|
58
|
+
cursorPos.current--;
|
|
59
|
+
rerender();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
deleteWordBackward: () => {
|
|
64
|
+
if (cursorPos.current > 0) {
|
|
65
|
+
const wordStart = findPrevWordStart(buffer.current, cursorPos.current);
|
|
66
|
+
buffer.current =
|
|
67
|
+
buffer.current.slice(0, wordStart) +
|
|
68
|
+
buffer.current.slice(cursorPos.current);
|
|
69
|
+
cursorPos.current = wordStart;
|
|
70
|
+
rerender();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
moveCursor: (position: number) => {
|
|
75
|
+
cursorPos.current = Math.max(0, Math.min(buffer.current.length, position));
|
|
76
|
+
rerender();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
clear: () => {
|
|
80
|
+
buffer.current = '';
|
|
81
|
+
cursorPos.current = 0;
|
|
82
|
+
rerender();
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
setValue: (value: string, cursorAtEnd = true) => {
|
|
86
|
+
buffer.current = value;
|
|
87
|
+
cursorPos.current = cursorAtEnd ? value.length : 0;
|
|
88
|
+
rerender();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
text: buffer.current,
|
|
94
|
+
cursorPosition: cursorPos.current,
|
|
95
|
+
actions,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirnamePreflight = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const projectRoot = join(__dirnamePreflight, '..');
|
|
8
|
+
|
|
9
|
+
// Pre-flight: check dependencies are installed
|
|
10
|
+
if (!existsSync(join(projectRoot, 'node_modules', 'react'))) {
|
|
11
|
+
console.error('Dependencies not installed. Run: bun install');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Pre-flight: warn if no .env file
|
|
16
|
+
if (!existsSync(join(projectRoot, '.env'))) {
|
|
17
|
+
console.warn('No .env file found. Copy the template: cp env.example .env');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import { render } from 'ink';
|
|
22
|
+
import { config } from 'dotenv';
|
|
23
|
+
import { CLI } from './cli.js';
|
|
24
|
+
|
|
25
|
+
// Handle --version / -v
|
|
26
|
+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'));
|
|
29
|
+
console.log(`brownian-code v${pkg.version}`);
|
|
30
|
+
} catch {
|
|
31
|
+
console.log('brownian-code');
|
|
32
|
+
}
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle --help / -h
|
|
37
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
38
|
+
console.log(`
|
|
39
|
+
Brownian Code - Autonomous crypto research agent
|
|
40
|
+
Think Claude Code, but built for crypto research.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
brownian Start interactive session
|
|
44
|
+
brownian --version Show version
|
|
45
|
+
brownian --help Show this help
|
|
46
|
+
|
|
47
|
+
Environment:
|
|
48
|
+
Set API keys in .env or enter them interactively.
|
|
49
|
+
See env.example for all supported variables.
|
|
50
|
+
|
|
51
|
+
Commands (inside the app):
|
|
52
|
+
/help Show commands & keyboard shortcuts
|
|
53
|
+
/model Switch model/provider
|
|
54
|
+
/clear Clear conversation history
|
|
55
|
+
/cost Show session token/cost stats
|
|
56
|
+
/compact Summarize & clear old context
|
|
57
|
+
/history Show past queries
|
|
58
|
+
/export Export conversation to file
|
|
59
|
+
/debug Toggle debug panel
|
|
60
|
+
/verbose Toggle verbose tool output
|
|
61
|
+
/shortcuts Show keyboard shortcuts
|
|
62
|
+
|
|
63
|
+
More info: https://brownian.xyz
|
|
64
|
+
`);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load environment variables
|
|
69
|
+
config({ quiet: true });
|
|
70
|
+
|
|
71
|
+
// Render the CLI app and wait for it to exit
|
|
72
|
+
// This keeps the process alive until the user exits
|
|
73
|
+
const { waitUntilExit } = render(<CLI />);
|
|
74
|
+
await waitUntilExit();
|