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.
Files changed (58) hide show
  1. package/README.md +99 -19
  2. package/dist/App.js +602 -0
  3. package/dist/agentic-loop.js +492 -525
  4. package/dist/cli.js +39 -0
  5. package/dist/components/CollapsibleBox.js +26 -0
  6. package/dist/components/ConfigDialog.js +40 -0
  7. package/dist/components/ConsolidatedToolMessage.js +41 -0
  8. package/dist/components/FormattedMessage.js +93 -0
  9. package/dist/components/Table.js +275 -0
  10. package/dist/config.js +171 -0
  11. package/dist/mcp.js +170 -0
  12. package/dist/providers.js +137 -0
  13. package/dist/sessions.js +161 -0
  14. package/dist/skills.js +229 -0
  15. package/dist/sub-agent.js +103 -0
  16. package/dist/system-prompt.js +131 -0
  17. package/dist/tools/bash.js +178 -0
  18. package/dist/tools/edit-file.js +65 -171
  19. package/dist/tools/index.js +79 -134
  20. package/dist/tools/list-directory.js +20 -73
  21. package/dist/tools/read-file.js +57 -101
  22. package/dist/tools/search-files.js +74 -162
  23. package/dist/tools/todo.js +57 -140
  24. package/dist/tools/webfetch.js +310 -0
  25. package/dist/tools/write-file.js +44 -135
  26. package/dist/utils/approval.js +69 -0
  27. package/dist/utils/compactor.js +87 -0
  28. package/dist/utils/cost-tracker.js +26 -81
  29. package/dist/utils/format-message.js +26 -0
  30. package/dist/utils/logger.js +101 -307
  31. package/dist/utils/path-validation.js +74 -0
  32. package/package.json +45 -51
  33. package/LICENSE +0 -21
  34. package/dist/config/client.js +0 -315
  35. package/dist/config/commands.js +0 -223
  36. package/dist/config/manager.js +0 -117
  37. package/dist/config/mcp-commands.js +0 -266
  38. package/dist/config/mcp-manager.js +0 -240
  39. package/dist/config/mcp-types.js +0 -28
  40. package/dist/config/providers.js +0 -229
  41. package/dist/config/setup.js +0 -209
  42. package/dist/config/system-prompt.js +0 -397
  43. package/dist/config/types.js +0 -4
  44. package/dist/index.js +0 -222
  45. package/dist/tools/create-directory.js +0 -76
  46. package/dist/tools/directory-operations.js +0 -195
  47. package/dist/tools/file-operations.js +0 -211
  48. package/dist/tools/run-shell-command.js +0 -746
  49. package/dist/tools/search-operations.js +0 -179
  50. package/dist/tools/shell-operations.js +0 -342
  51. package/dist/tools/task-complete.js +0 -26
  52. package/dist/tools/view-directory-tree.js +0 -125
  53. package/dist/tools.js +0 -2
  54. package/dist/utils/conversation-compactor.js +0 -140
  55. package/dist/utils/enhanced-prompt.js +0 -23
  56. package/dist/utils/file-operations-approval.js +0 -373
  57. package/dist/utils/interrupt-handler.js +0 -127
  58. 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
+ };