protoagent 0.0.4 → 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.
- package/README.md +99 -19
- package/dist/App.js +602 -0
- package/dist/agentic-loop.js +492 -525
- package/dist/cli.js +39 -0
- package/dist/components/CollapsibleBox.js +26 -0
- package/dist/components/ConfigDialog.js +40 -0
- package/dist/components/ConsolidatedToolMessage.js +41 -0
- package/dist/components/FormattedMessage.js +93 -0
- package/dist/components/Table.js +275 -0
- package/dist/config.js +171 -0
- package/dist/mcp.js +170 -0
- package/dist/providers.js +137 -0
- package/dist/sessions.js +161 -0
- package/dist/skills.js +229 -0
- package/dist/sub-agent.js +103 -0
- package/dist/system-prompt.js +131 -0
- package/dist/tools/bash.js +178 -0
- package/dist/tools/edit-file.js +65 -171
- package/dist/tools/index.js +79 -134
- package/dist/tools/list-directory.js +20 -73
- package/dist/tools/read-file.js +57 -101
- package/dist/tools/search-files.js +74 -162
- package/dist/tools/todo.js +57 -140
- package/dist/tools/webfetch.js +310 -0
- package/dist/tools/write-file.js +44 -135
- package/dist/utils/approval.js +69 -0
- package/dist/utils/compactor.js +87 -0
- package/dist/utils/cost-tracker.js +26 -81
- package/dist/utils/format-message.js +26 -0
- package/dist/utils/logger.js +101 -307
- package/dist/utils/path-validation.js +74 -0
- package/package.json +45 -51
- package/LICENSE +0 -21
- package/dist/config/client.js +0 -315
- package/dist/config/commands.js +0 -223
- package/dist/config/manager.js +0 -117
- package/dist/config/mcp-commands.js +0 -266
- package/dist/config/mcp-manager.js +0 -240
- package/dist/config/mcp-types.js +0 -28
- package/dist/config/providers.js +0 -229
- package/dist/config/setup.js +0 -209
- package/dist/config/system-prompt.js +0 -397
- package/dist/config/types.js +0 -4
- package/dist/index.js +0 -222
- package/dist/tools/create-directory.js +0 -76
- package/dist/tools/directory-operations.js +0 -195
- package/dist/tools/file-operations.js +0 -211
- package/dist/tools/run-shell-command.js +0 -746
- package/dist/tools/search-operations.js +0 -179
- package/dist/tools/shell-operations.js +0 -342
- package/dist/tools/task-complete.js +0 -26
- package/dist/tools/view-directory-tree.js +0 -125
- package/dist/tools.js +0 -2
- package/dist/utils/conversation-compactor.js +0 -140
- package/dist/utils/enhanced-prompt.js +0 -23
- package/dist/utils/file-operations-approval.js +0 -373
- package/dist/utils/interrupt-handler.js +0 -127
- package/dist/utils/user-cancellation.js +0 -34
package/dist/App.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Main UI component — the heart of ProtoAgent's terminal interface.
|
|
4
|
+
*
|
|
5
|
+
* Renders the chat loop, tool call feedback, approval prompts,
|
|
6
|
+
* and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
|
|
7
|
+
* this file is purely presentation + state wiring.
|
|
8
|
+
*/
|
|
9
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
10
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
11
|
+
import { TextInput, Select, PasswordInput } from '@inkjs/ui';
|
|
12
|
+
import BigText from 'ink-big-text';
|
|
13
|
+
import { OpenAI } from 'openai';
|
|
14
|
+
import { readConfig, writeConfig, resolveApiKey } from './config.js';
|
|
15
|
+
import { getProvider, getModelPricing, SUPPORTED_MODELS } from './providers.js';
|
|
16
|
+
import { runAgenticLoop, initializeMessages, } from './agentic-loop.js';
|
|
17
|
+
import { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from './tools/index.js';
|
|
18
|
+
import { setLogLevel, LogLevel, initLogFile, logger } from './utils/logger.js';
|
|
19
|
+
import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, generateTitle, } from './sessions.js';
|
|
20
|
+
import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
|
|
21
|
+
import { initializeMcp, closeMcp } from './mcp.js';
|
|
22
|
+
import { generateSystemPrompt } from './system-prompt.js';
|
|
23
|
+
import { CollapsibleBox } from './components/CollapsibleBox.js';
|
|
24
|
+
import { ConsolidatedToolMessage } from './components/ConsolidatedToolMessage.js';
|
|
25
|
+
import { FormattedMessage } from './components/FormattedMessage.js';
|
|
26
|
+
function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0) {
|
|
27
|
+
const rendered = [];
|
|
28
|
+
const skippedIndices = new Set();
|
|
29
|
+
messagesToRender.forEach((msg, localIndex) => {
|
|
30
|
+
if (skippedIndices.has(localIndex)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const index = startIndex + localIndex;
|
|
34
|
+
const msgAny = msg;
|
|
35
|
+
const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
|
|
36
|
+
const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
|
|
37
|
+
const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
|
|
38
|
+
const previousMessage = index > 0 ? allMessages[index - 1] : null;
|
|
39
|
+
const followsToolMessage = previousMessage?.role === 'tool';
|
|
40
|
+
const currentSpeaker = getVisualSpeaker(msg);
|
|
41
|
+
const previousSpeaker = getVisualSpeaker(previousMessage);
|
|
42
|
+
const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
|
|
43
|
+
const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
|
|
44
|
+
const speakerChanged = previousSpeaker !== currentSpeaker;
|
|
45
|
+
if (isFirstSystemMessage) {
|
|
46
|
+
rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
|
|
47
|
+
}
|
|
48
|
+
if (isConversationTurn && previousWasConversationTurn && speakerChanged) {
|
|
49
|
+
rendered.push(_jsx(Text, { children: " " }, `turn-spacer-${index}`));
|
|
50
|
+
}
|
|
51
|
+
if (msg.role === 'user') {
|
|
52
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (msg.role === 'system') {
|
|
56
|
+
rendered.push(_jsx(CollapsibleBox, { title: "System Prompt", content: displayContent || '', titleColor: "green", dimColor: false, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (isToolCall) {
|
|
60
|
+
if (displayContent && displayContent.trim().length > 0) {
|
|
61
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: displayContent.trimEnd() }) }, `${index}-text`));
|
|
62
|
+
}
|
|
63
|
+
const toolCalls = msgAny.tool_calls.map((tc) => ({
|
|
64
|
+
id: tc.id,
|
|
65
|
+
name: tc.function?.name || 'tool',
|
|
66
|
+
}));
|
|
67
|
+
const toolResults = new Map();
|
|
68
|
+
let nextLocalIndex = localIndex + 1;
|
|
69
|
+
for (const toolCall of toolCalls) {
|
|
70
|
+
if (nextLocalIndex < messagesToRender.length) {
|
|
71
|
+
const nextMsg = messagesToRender[nextLocalIndex];
|
|
72
|
+
if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
|
|
73
|
+
toolResults.set(toolCall.id, {
|
|
74
|
+
content: nextMsg.content || '',
|
|
75
|
+
name: nextMsg.name || toolCall.name,
|
|
76
|
+
});
|
|
77
|
+
skippedIndices.add(nextLocalIndex);
|
|
78
|
+
nextLocalIndex++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
rendered.push(_jsx(ConsolidatedToolMessage, { toolCalls: toolCalls, toolResults: toolResults, expanded: expandedMessages.has(index) }, index));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (msg.role === 'tool') {
|
|
86
|
+
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: displayContent || '', dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: trimAssistantSpacing(displayContent || '', followsToolMessage ? 'start' : 'both') }) }, index));
|
|
90
|
+
});
|
|
91
|
+
return rendered;
|
|
92
|
+
}
|
|
93
|
+
function trimAssistantSpacing(message, trimMode = 'both') {
|
|
94
|
+
if (trimMode === 'start')
|
|
95
|
+
return message.trimStart();
|
|
96
|
+
if (trimMode === 'end')
|
|
97
|
+
return message.trimEnd();
|
|
98
|
+
return message.trim();
|
|
99
|
+
}
|
|
100
|
+
function getVisualSpeaker(message) {
|
|
101
|
+
if (!message)
|
|
102
|
+
return null;
|
|
103
|
+
if (message.role === 'tool')
|
|
104
|
+
return 'assistant';
|
|
105
|
+
if (message.role === 'user' || message.role === 'assistant' || message.role === 'system') {
|
|
106
|
+
return message.role;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// ─── Available slash commands ───
|
|
111
|
+
const SLASH_COMMANDS = [
|
|
112
|
+
{ name: '/clear', description: 'Clear conversation and start fresh' },
|
|
113
|
+
{ name: '/collapse', description: 'Collapse all long messages' },
|
|
114
|
+
{ name: '/expand', description: 'Expand all collapsed messages' },
|
|
115
|
+
{ name: '/help', description: 'Show all available commands' },
|
|
116
|
+
{ name: '/quit', description: 'Exit ProtoAgent' },
|
|
117
|
+
];
|
|
118
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
119
|
+
const HELP_TEXT = [
|
|
120
|
+
'Commands:',
|
|
121
|
+
' /clear - Clear conversation and start fresh',
|
|
122
|
+
' /collapse - Collapse all long messages',
|
|
123
|
+
' /expand - Expand all collapsed messages',
|
|
124
|
+
' /help - Show this help',
|
|
125
|
+
' /quit - Exit ProtoAgent',
|
|
126
|
+
].join('\n');
|
|
127
|
+
function buildClient(config) {
|
|
128
|
+
const provider = getProvider(config.provider);
|
|
129
|
+
const apiKey = resolveApiKey(config);
|
|
130
|
+
if (!apiKey) {
|
|
131
|
+
const providerName = provider?.name || config.provider;
|
|
132
|
+
const envVar = provider?.apiKeyEnvVar;
|
|
133
|
+
throw new Error(envVar
|
|
134
|
+
? `Missing API key for ${providerName}. Set it in config or export ${envVar}.`
|
|
135
|
+
: `Missing API key for ${providerName}.`);
|
|
136
|
+
}
|
|
137
|
+
const clientOptions = {
|
|
138
|
+
apiKey,
|
|
139
|
+
};
|
|
140
|
+
if (provider?.baseURL) {
|
|
141
|
+
clientOptions.baseURL = provider.baseURL;
|
|
142
|
+
}
|
|
143
|
+
return new OpenAI(clientOptions);
|
|
144
|
+
}
|
|
145
|
+
// ─── Sub-components ───
|
|
146
|
+
/** Shows filtered slash commands when user types /. */
|
|
147
|
+
const CommandFilter = ({ inputText }) => {
|
|
148
|
+
if (!inputText.startsWith('/'))
|
|
149
|
+
return null;
|
|
150
|
+
const filtered = SLASH_COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(inputText.toLowerCase()));
|
|
151
|
+
if (filtered.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: filtered.map((cmd) => (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "green", children: cmd.name }), " \u2014 ", cmd.description] }, cmd.name))) }));
|
|
154
|
+
};
|
|
155
|
+
/** Interactive approval prompt rendered inline. */
|
|
156
|
+
const ApprovalPrompt = ({ request, onRespond }) => {
|
|
157
|
+
const sessionApprovalLabel = request.sessionScopeKey
|
|
158
|
+
? 'Approve this operation for session'
|
|
159
|
+
: `Approve all "${request.type}" for session`;
|
|
160
|
+
const items = [
|
|
161
|
+
{ label: 'Approve once', value: 'approve_once' },
|
|
162
|
+
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
163
|
+
{ label: 'Reject', value: 'reject' },
|
|
164
|
+
];
|
|
165
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "Approval Required" }), _jsx(Text, { children: request.description }), request.detail && (_jsx(Text, { dimColor: true, children: request.detail.length > 200 ? request.detail.slice(0, 200) + '...' : request.detail })), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: items.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => onRespond(value) }) })] }));
|
|
166
|
+
};
|
|
167
|
+
/** Cost/usage display in the status bar. */
|
|
168
|
+
const UsageDisplay = ({ usage, totalCost }) => {
|
|
169
|
+
if (!usage && totalCost === 0)
|
|
170
|
+
return null;
|
|
171
|
+
return (_jsxs(Box, { children: [usage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191 | ctx: ", usage.contextPercent.toFixed(0), "%"] })), totalCost > 0 && (_jsxs(Text, { dimColor: true, children: [" | cost: $", totalCost.toFixed(4)] }))] }));
|
|
172
|
+
};
|
|
173
|
+
/** Inline setup wizard — shown when no config exists. */
|
|
174
|
+
const InlineSetup = ({ onComplete }) => {
|
|
175
|
+
const [setupStep, setSetupStep] = useState('provider');
|
|
176
|
+
const [selectedProviderId, setSelectedProviderId] = useState('');
|
|
177
|
+
const [selectedModelId, setSelectedModelId] = useState('');
|
|
178
|
+
const [apiKeyError, setApiKeyError] = useState('');
|
|
179
|
+
const providerItems = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
|
|
180
|
+
label: `${provider.name} - ${model.name}`,
|
|
181
|
+
value: `${provider.id}:::${model.id}`,
|
|
182
|
+
})));
|
|
183
|
+
if (setupStep === 'provider') {
|
|
184
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsx(Text, { dimColor: true, children: "Select a provider and model:" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: providerItems.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => {
|
|
185
|
+
const [providerId, modelId] = value.split(':::');
|
|
186
|
+
setSelectedProviderId(providerId);
|
|
187
|
+
setSelectedModelId(modelId);
|
|
188
|
+
setSetupStep('api_key');
|
|
189
|
+
} }) })] }));
|
|
190
|
+
}
|
|
191
|
+
const provider = getProvider(selectedProviderId);
|
|
192
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsxs(Text, { dimColor: true, children: ["Selected: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: "Enter your API key:" }), apiKeyError && _jsx(Text, { color: "red", children: apiKeyError }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
|
|
193
|
+
if (value.trim().length === 0) {
|
|
194
|
+
setApiKeyError('API key cannot be empty.');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const newConfig = {
|
|
198
|
+
provider: selectedProviderId,
|
|
199
|
+
model: selectedModelId,
|
|
200
|
+
apiKey: value.trim(),
|
|
201
|
+
};
|
|
202
|
+
writeConfig(newConfig);
|
|
203
|
+
onComplete(newConfig);
|
|
204
|
+
} })] }));
|
|
205
|
+
};
|
|
206
|
+
// ─── Main App ───
|
|
207
|
+
export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
208
|
+
const { exit } = useApp();
|
|
209
|
+
// Core state
|
|
210
|
+
const [config, setConfig] = useState(null);
|
|
211
|
+
const [completionMessages, setCompletionMessages] = useState([]);
|
|
212
|
+
const [inputText, setInputText] = useState('');
|
|
213
|
+
const [loading, setLoading] = useState(false);
|
|
214
|
+
const [error, setError] = useState(null);
|
|
215
|
+
const [helpMessage, setHelpMessage] = useState(null);
|
|
216
|
+
const [threadErrors, setThreadErrors] = useState([]);
|
|
217
|
+
const [initialized, setInitialized] = useState(false);
|
|
218
|
+
const [needsSetup, setNeedsSetup] = useState(false);
|
|
219
|
+
const [logFilePath, setLogFilePath] = useState(null);
|
|
220
|
+
// Collapsible state — track which message indices are expanded
|
|
221
|
+
const [expandedMessages, setExpandedMessages] = useState(new Set());
|
|
222
|
+
const expandLatestMessage = useCallback((index) => {
|
|
223
|
+
setExpandedMessages((prev) => {
|
|
224
|
+
if (prev.has(index))
|
|
225
|
+
return prev;
|
|
226
|
+
const next = new Set(prev);
|
|
227
|
+
next.add(index);
|
|
228
|
+
return next;
|
|
229
|
+
});
|
|
230
|
+
}, []);
|
|
231
|
+
// Approval state
|
|
232
|
+
const [pendingApproval, setPendingApproval] = useState(null);
|
|
233
|
+
// Usage state
|
|
234
|
+
const [lastUsage, setLastUsage] = useState(null);
|
|
235
|
+
const [totalCost, setTotalCost] = useState(0);
|
|
236
|
+
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
237
|
+
// Session state
|
|
238
|
+
const [session, setSession] = useState(null);
|
|
239
|
+
// Quitting state — shows the resume command before exiting
|
|
240
|
+
const [quittingSession, setQuittingSession] = useState(null);
|
|
241
|
+
// OpenAI client ref (stable across renders)
|
|
242
|
+
const clientRef = useRef(null);
|
|
243
|
+
// Track current assistant message being built in the event handler
|
|
244
|
+
const assistantMessageRef = useRef(null);
|
|
245
|
+
// Abort controller for cancelling the current completion
|
|
246
|
+
const abortControllerRef = useRef(null);
|
|
247
|
+
// ─── Post-config initialization (reused after inline setup) ───
|
|
248
|
+
const initializeWithConfig = useCallback(async (loadedConfig) => {
|
|
249
|
+
setConfig(loadedConfig);
|
|
250
|
+
clientRef.current = buildClient(loadedConfig);
|
|
251
|
+
// Initialize MCP servers
|
|
252
|
+
await initializeMcp();
|
|
253
|
+
// Load or create session
|
|
254
|
+
let loadedSession = null;
|
|
255
|
+
if (sessionId) {
|
|
256
|
+
loadedSession = await loadSession(sessionId);
|
|
257
|
+
if (loadedSession) {
|
|
258
|
+
const systemPrompt = await generateSystemPrompt();
|
|
259
|
+
loadedSession.completionMessages = ensureSystemPromptAtTop(loadedSession.completionMessages, systemPrompt);
|
|
260
|
+
setTodosForSession(loadedSession.id, loadedSession.todos);
|
|
261
|
+
setSession(loadedSession);
|
|
262
|
+
setCompletionMessages(loadedSession.completionMessages);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
setError(`Session "${sessionId}" not found. Starting a new session.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!loadedSession) {
|
|
269
|
+
// Initialize fresh conversation
|
|
270
|
+
const initialCompletionMessages = await initializeMessages();
|
|
271
|
+
setCompletionMessages(initialCompletionMessages);
|
|
272
|
+
const newSession = createSession(loadedConfig.model, loadedConfig.provider);
|
|
273
|
+
clearTodos(newSession.id);
|
|
274
|
+
newSession.completionMessages = initialCompletionMessages;
|
|
275
|
+
setSession(newSession);
|
|
276
|
+
}
|
|
277
|
+
setNeedsSetup(false);
|
|
278
|
+
setInitialized(true);
|
|
279
|
+
}, [sessionId]);
|
|
280
|
+
// ─── Initialization ───
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (!loading) {
|
|
283
|
+
setSpinnerFrame(0);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const interval = setInterval(() => {
|
|
287
|
+
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
|
|
288
|
+
}, 100);
|
|
289
|
+
return () => clearInterval(interval);
|
|
290
|
+
}, [loading]);
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
const init = async () => {
|
|
293
|
+
// Set log level and initialize log file
|
|
294
|
+
if (logLevel) {
|
|
295
|
+
const level = LogLevel[logLevel.toUpperCase()];
|
|
296
|
+
if (level !== undefined) {
|
|
297
|
+
setLogLevel(level);
|
|
298
|
+
const logPath = initLogFile();
|
|
299
|
+
setLogFilePath(logPath);
|
|
300
|
+
logger.info(`ProtoAgent started with log level: ${logLevel}`);
|
|
301
|
+
logger.info(`Log file: ${logPath}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Set global approval mode
|
|
305
|
+
if (dangerouslyAcceptAll) {
|
|
306
|
+
setDangerouslyAcceptAll(true);
|
|
307
|
+
}
|
|
308
|
+
// Register interactive approval handler
|
|
309
|
+
setApprovalHandler(async (req) => {
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
setPendingApproval({ request: req, resolve });
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
// Load config — if none exists, show inline setup
|
|
315
|
+
const loadedConfig = readConfig();
|
|
316
|
+
if (!loadedConfig) {
|
|
317
|
+
setNeedsSetup(true);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
await initializeWithConfig(loadedConfig);
|
|
321
|
+
};
|
|
322
|
+
init().catch((err) => {
|
|
323
|
+
setError(`Initialization failed: ${err.message}`);
|
|
324
|
+
});
|
|
325
|
+
// Cleanup on unmount
|
|
326
|
+
return () => {
|
|
327
|
+
clearApprovalHandler();
|
|
328
|
+
closeMcp();
|
|
329
|
+
};
|
|
330
|
+
}, []);
|
|
331
|
+
// ─── Slash commands ───
|
|
332
|
+
const handleSlashCommand = useCallback(async (cmd) => {
|
|
333
|
+
const parts = cmd.trim().split(/\s+/);
|
|
334
|
+
const command = parts[0]?.toLowerCase();
|
|
335
|
+
switch (command) {
|
|
336
|
+
case '/quit':
|
|
337
|
+
case '/exit':
|
|
338
|
+
if (!session) {
|
|
339
|
+
exit();
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const nextSession = {
|
|
344
|
+
...session,
|
|
345
|
+
completionMessages,
|
|
346
|
+
todos: getTodosForSession(session.id),
|
|
347
|
+
title: generateTitle(completionMessages),
|
|
348
|
+
};
|
|
349
|
+
await saveSession(nextSession);
|
|
350
|
+
setSession(nextSession);
|
|
351
|
+
setQuittingSession(nextSession);
|
|
352
|
+
setError(null);
|
|
353
|
+
// Exit after a short delay to allow render
|
|
354
|
+
setTimeout(() => exit(), 100);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
setError(`Failed to save session before exit: ${err.message}`);
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
case '/clear':
|
|
361
|
+
// Re-initialize messages with just the system prompt
|
|
362
|
+
initializeMessages().then((msgs) => {
|
|
363
|
+
setCompletionMessages(msgs);
|
|
364
|
+
setHelpMessage(null);
|
|
365
|
+
setLastUsage(null);
|
|
366
|
+
setTotalCost(0);
|
|
367
|
+
setThreadErrors([]);
|
|
368
|
+
setExpandedMessages(new Set());
|
|
369
|
+
if (session) {
|
|
370
|
+
const newSession = createSession(config.model, config.provider);
|
|
371
|
+
clearTodos(session.id);
|
|
372
|
+
clearTodos(newSession.id);
|
|
373
|
+
newSession.completionMessages = msgs;
|
|
374
|
+
setSession(newSession);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return true;
|
|
378
|
+
case '/expand':
|
|
379
|
+
// Expand all collapsed messages
|
|
380
|
+
const allIndices = new Set(completionMessages.map((_, i) => i));
|
|
381
|
+
setExpandedMessages(allIndices);
|
|
382
|
+
return true;
|
|
383
|
+
case '/collapse':
|
|
384
|
+
// Collapse all messages
|
|
385
|
+
setExpandedMessages(new Set());
|
|
386
|
+
return true;
|
|
387
|
+
case '/help':
|
|
388
|
+
setHelpMessage(HELP_TEXT);
|
|
389
|
+
return true;
|
|
390
|
+
default:
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}, [exit, session, completionMessages]);
|
|
394
|
+
// ─── Submit handler ───
|
|
395
|
+
const handleSubmit = useCallback(async (value) => {
|
|
396
|
+
const trimmed = value.trim();
|
|
397
|
+
if (!trimmed || loading || !clientRef.current || !config)
|
|
398
|
+
return;
|
|
399
|
+
// Handle slash commands
|
|
400
|
+
if (trimmed.startsWith('/')) {
|
|
401
|
+
const handled = await handleSlashCommand(trimmed);
|
|
402
|
+
if (handled) {
|
|
403
|
+
setInputText('');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
setInputText('');
|
|
408
|
+
setLoading(true);
|
|
409
|
+
setError(null);
|
|
410
|
+
setHelpMessage(null);
|
|
411
|
+
setThreadErrors([]);
|
|
412
|
+
// Add user message to completion messages IMMEDIATELY for real-time UI display
|
|
413
|
+
const userMessage = { role: 'user', content: trimmed };
|
|
414
|
+
setCompletionMessages((prev) => [...prev, userMessage]);
|
|
415
|
+
// Reset assistant message tracker
|
|
416
|
+
assistantMessageRef.current = null;
|
|
417
|
+
try {
|
|
418
|
+
const pricing = getModelPricing(config.provider, config.model);
|
|
419
|
+
// Create abort controller for this completion
|
|
420
|
+
abortControllerRef.current = new AbortController();
|
|
421
|
+
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
|
|
422
|
+
switch (event.type) {
|
|
423
|
+
case 'text_delta':
|
|
424
|
+
// Update the current assistant message in completionMessages in real-time
|
|
425
|
+
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
426
|
+
// First text delta - create the assistant message
|
|
427
|
+
const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
|
|
428
|
+
setCompletionMessages((prev) => {
|
|
429
|
+
assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
|
|
430
|
+
return [...prev, assistantMsg];
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
// Subsequent text delta - update the assistant message
|
|
435
|
+
assistantMessageRef.current.message.content += event.content || '';
|
|
436
|
+
setCompletionMessages((prev) => {
|
|
437
|
+
const updated = [...prev];
|
|
438
|
+
updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
|
|
439
|
+
return updated;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
case 'tool_call':
|
|
444
|
+
if (event.toolCall) {
|
|
445
|
+
const toolCall = event.toolCall;
|
|
446
|
+
setCompletionMessages((prev) => {
|
|
447
|
+
const existingRef = assistantMessageRef.current;
|
|
448
|
+
const existingMessage = existingRef?.message
|
|
449
|
+
? {
|
|
450
|
+
...existingRef.message,
|
|
451
|
+
tool_calls: [...(existingRef.message.tool_calls || [])],
|
|
452
|
+
}
|
|
453
|
+
: null;
|
|
454
|
+
const assistantMsg = existingMessage || {
|
|
455
|
+
role: 'assistant',
|
|
456
|
+
content: '',
|
|
457
|
+
tool_calls: [],
|
|
458
|
+
};
|
|
459
|
+
const existingToolCallIndex = assistantMsg.tool_calls.findIndex((existingToolCall) => existingToolCall.id === toolCall.id);
|
|
460
|
+
const nextToolCall = {
|
|
461
|
+
id: toolCall.id,
|
|
462
|
+
type: 'function',
|
|
463
|
+
function: {
|
|
464
|
+
name: toolCall.name,
|
|
465
|
+
arguments: toolCall.args,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
if (existingToolCallIndex === -1) {
|
|
469
|
+
assistantMsg.tool_calls.push(nextToolCall);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
assistantMsg.tool_calls[existingToolCallIndex] = nextToolCall;
|
|
473
|
+
}
|
|
474
|
+
const nextIndex = existingRef?.index ?? prev.length;
|
|
475
|
+
assistantMessageRef.current = {
|
|
476
|
+
message: assistantMsg,
|
|
477
|
+
index: nextIndex,
|
|
478
|
+
kind: 'tool_call_assistant',
|
|
479
|
+
};
|
|
480
|
+
if (existingRef) {
|
|
481
|
+
const updated = [...prev];
|
|
482
|
+
updated[existingRef.index] = assistantMsg;
|
|
483
|
+
return updated;
|
|
484
|
+
}
|
|
485
|
+
return [...prev, assistantMsg];
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
break;
|
|
489
|
+
case 'tool_result':
|
|
490
|
+
if (event.toolCall) {
|
|
491
|
+
const toolCall = event.toolCall;
|
|
492
|
+
if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
|
|
493
|
+
const currentAssistantIndex = assistantMessageRef.current?.index;
|
|
494
|
+
if (typeof currentAssistantIndex === 'number') {
|
|
495
|
+
expandLatestMessage(currentAssistantIndex);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// Add tool result message to completion messages
|
|
499
|
+
setCompletionMessages((prev) => [
|
|
500
|
+
...prev,
|
|
501
|
+
{
|
|
502
|
+
role: 'tool',
|
|
503
|
+
tool_call_id: toolCall.id,
|
|
504
|
+
content: toolCall.result || '',
|
|
505
|
+
name: toolCall.name,
|
|
506
|
+
},
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
break;
|
|
510
|
+
case 'usage':
|
|
511
|
+
if (event.usage) {
|
|
512
|
+
setLastUsage(event.usage);
|
|
513
|
+
setTotalCost((prev) => prev + event.usage.cost);
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
case 'error':
|
|
517
|
+
if (event.error) {
|
|
518
|
+
const errorMessage = event.error;
|
|
519
|
+
setThreadErrors((prev) => {
|
|
520
|
+
if (event.transient) {
|
|
521
|
+
return [
|
|
522
|
+
...prev.filter((threadError) => !threadError.transient),
|
|
523
|
+
{
|
|
524
|
+
id: `${Date.now()}-${prev.length}`,
|
|
525
|
+
message: errorMessage,
|
|
526
|
+
transient: true,
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
if (prev[prev.length - 1]?.message === errorMessage) {
|
|
531
|
+
return prev;
|
|
532
|
+
}
|
|
533
|
+
return [
|
|
534
|
+
...prev,
|
|
535
|
+
{
|
|
536
|
+
id: `${Date.now()}-${prev.length}`,
|
|
537
|
+
message: errorMessage,
|
|
538
|
+
transient: false,
|
|
539
|
+
},
|
|
540
|
+
];
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
setError('Unknown error');
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
case 'done':
|
|
548
|
+
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}, { pricing: pricing || undefined, abortSignal: abortControllerRef.current.signal, sessionId: session?.id });
|
|
552
|
+
// Final update to ensure we have the complete message history
|
|
553
|
+
setCompletionMessages(updatedMessages);
|
|
554
|
+
// Update session
|
|
555
|
+
if (session) {
|
|
556
|
+
session.completionMessages = updatedMessages;
|
|
557
|
+
session.todos = getTodosForSession(session.id);
|
|
558
|
+
session.title = generateTitle(updatedMessages);
|
|
559
|
+
await saveSession(session);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
setError(`Error: ${err.message}`);
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
setLoading(false);
|
|
567
|
+
}
|
|
568
|
+
}, [loading, config, completionMessages, session, handleSlashCommand, expandLatestMessage]);
|
|
569
|
+
// ─── Keyboard shortcuts ───
|
|
570
|
+
useInput((input, key) => {
|
|
571
|
+
if (key.ctrl && input === 'c') {
|
|
572
|
+
exit();
|
|
573
|
+
}
|
|
574
|
+
if (key.escape && loading && abortControllerRef.current) {
|
|
575
|
+
// Abort the current completion
|
|
576
|
+
abortControllerRef.current.abort();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
// ─── Render ───
|
|
580
|
+
const providerInfo = config ? getProvider(config.provider) : null;
|
|
581
|
+
const liveStartIndex = loading
|
|
582
|
+
? (typeof assistantMessageRef.current?.index === 'number'
|
|
583
|
+
? assistantMessageRef.current.index
|
|
584
|
+
: Math.max(completionMessages.length - 1, 0))
|
|
585
|
+
: completionMessages.length;
|
|
586
|
+
const archivedMessages = completionMessages.slice(0, liveStartIndex);
|
|
587
|
+
const liveMessages = completionMessages.slice(liveStartIndex);
|
|
588
|
+
const archivedMessageNodes = renderMessageList(archivedMessages, completionMessages, expandedMessages);
|
|
589
|
+
const liveMessageNodes = renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex);
|
|
590
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }), _jsx(Text, { italic: true, dimColor: true, children: "\"The prefix \"proto-\" comes from the Greek word pr\u014Dtos \u2014 the beginning stage of something that will later evolve.\"" }), _jsx(Text, { children: " " }), config && (_jsxs(Text, { dimColor: true, children: ["Model: ", providerInfo?.name || config.provider, " / ", config.model, dangerouslyAcceptAll && _jsx(Text, { color: "red", children: " (auto-approve all)" }), session && _jsxs(Text, { dimColor: true, children: [" | Session: ", session.id.slice(0, 8)] })] })), logFilePath && (_jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] })), error && _jsx(Text, { color: "red", children: error }), helpMessage && (_jsx(CollapsibleBox, { title: "Help", content: helpMessage, titleColor: "green", dimColor: false, maxPreviewLines: 10, expanded: true })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
|
|
591
|
+
initializeWithConfig(newConfig).catch((err) => {
|
|
592
|
+
setError(`Initialization failed: ${err.message}`);
|
|
593
|
+
});
|
|
594
|
+
} })), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflowY: "hidden", children: [archivedMessageNodes, liveMessageNodes, threadErrors.map((threadError) => (_jsx(Box, { marginBottom: 1, borderStyle: "round", borderColor: "red", paddingX: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }) }, `thread-error-${threadError.id}`))), loading && completionMessages.length > 0 && ((() => {
|
|
595
|
+
const lastMsg = completionMessages[completionMessages.length - 1];
|
|
596
|
+
// Show "Thinking..." only if the last message is a user message (no assistant response yet)
|
|
597
|
+
return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
|
|
598
|
+
})()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
|
|
599
|
+
pendingApproval.resolve(response);
|
|
600
|
+
setPendingApproval(null);
|
|
601
|
+
} }))] }), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], " Working..."] }) })), initialized && !pendingApproval && (_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", children: '> ' }), _jsx(TextInput, { defaultValue: inputText, onChange: setInputText, placeholder: "Type your message... (/help for commands)", onSubmit: handleSubmit }, inputText === '' ? 'reset' : 'active')] })), quittingSession && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Session saved. Resume with:" }), _jsxs(Text, { color: "green", children: ["protoagent --session ", quittingSession.id] })] }))] }));
|
|
602
|
+
};
|