protoagent 0.1.2 → 0.1.4

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/dist/App.js CHANGED
@@ -6,13 +6,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
7
7
  * this file is purely presentation + state wiring.
8
8
  */
9
- import { useState, useEffect, useCallback, useRef } from 'react';
10
- import { Box, Text, useApp, useInput } from 'ink';
9
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
10
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { TextInput, Select, PasswordInput } from '@inkjs/ui';
12
12
  import BigText from 'ink-big-text';
13
13
  import { OpenAI } from 'openai';
14
14
  import { readConfig, writeConfig, resolveApiKey } from './config.js';
15
- import { getProvider, getModelPricing, SUPPORTED_MODELS } from './providers.js';
15
+ import { loadRuntimeConfig } from './runtime-config.js';
16
+ import { getAllProviders, getProvider, getModelPricing, getRequestDefaultParams } from './providers.js';
16
17
  import { runAgenticLoop, initializeMessages, } from './agentic-loop.js';
17
18
  import { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from './tools/index.js';
18
19
  import { setLogLevel, LogLevel, initLogFile, logger } from './utils/logger.js';
@@ -23,7 +24,7 @@ import { generateSystemPrompt } from './system-prompt.js';
23
24
  import { CollapsibleBox } from './components/CollapsibleBox.js';
24
25
  import { ConsolidatedToolMessage } from './components/ConsolidatedToolMessage.js';
25
26
  import { FormattedMessage } from './components/FormattedMessage.js';
26
- function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0) {
27
+ function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0, deferTables = false) {
27
28
  const rendered = [];
28
29
  const skippedIndices = new Set();
29
30
  messagesToRender.forEach((msg, localIndex) => {
@@ -34,6 +35,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
34
35
  const msgAny = msg;
35
36
  const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
36
37
  const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
38
+ const normalizedContent = normalizeMessageSpacing(displayContent || '');
37
39
  const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
38
40
  const previousMessage = index > 0 ? allMessages[index - 1] : null;
39
41
  const followsToolMessage = previousMessage?.role === 'tool';
@@ -42,12 +44,15 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
42
44
  const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
43
45
  const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
44
46
  const speakerChanged = previousSpeaker !== currentSpeaker;
45
- if (isFirstSystemMessage) {
47
+ // Determine if we need a blank-line spacer above this message.
48
+ // At most one spacer is added per message to avoid doubling.
49
+ const needsSpacer = isFirstSystemMessage ||
50
+ (isConversationTurn && previousWasConversationTurn && speakerChanged) ||
51
+ followsToolMessage ||
52
+ (isToolCall && previousMessage != null);
53
+ if (needsSpacer) {
46
54
  rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
47
55
  }
48
- if (isConversationTurn && previousWasConversationTurn && speakerChanged) {
49
- rendered.push(_jsx(Text, { children: " " }, `turn-spacer-${index}`));
50
- }
51
56
  if (msg.role === 'user') {
52
57
  rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
53
58
  return;
@@ -57,8 +62,8 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
57
62
  return;
58
63
  }
59
64
  if (isToolCall) {
60
- if (displayContent && displayContent.trim().length > 0) {
61
- rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: displayContent.trimEnd() }) }, `${index}-text`));
65
+ if (normalizedContent.length > 0) {
66
+ rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, `${index}-text`));
62
67
  }
63
68
  const toolCalls = msgAny.tool_calls.map((tc) => ({
64
69
  id: tc.id,
@@ -71,7 +76,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
71
76
  const nextMsg = messagesToRender[nextLocalIndex];
72
77
  if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
73
78
  toolResults.set(toolCall.id, {
74
- content: nextMsg.content || '',
79
+ content: normalizeMessageSpacing(nextMsg.content || ''),
75
80
  name: nextMsg.name || toolCall.name,
76
81
  });
77
82
  skippedIndices.add(nextLocalIndex);
@@ -83,19 +88,23 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
83
88
  return;
84
89
  }
85
90
  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));
91
+ rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: normalizedContent, dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
87
92
  return;
88
93
  }
89
- rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: trimAssistantSpacing(displayContent || '', followsToolMessage ? 'start' : 'both') }) }, index));
94
+ rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, index));
90
95
  });
91
96
  return rendered;
92
97
  }
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();
98
+ function normalizeMessageSpacing(message) {
99
+ const normalized = message.replace(/\r\n/g, '\n');
100
+ const lines = normalized.split('\n');
101
+ while (lines.length > 0 && lines[0].trim() === '') {
102
+ lines.shift();
103
+ }
104
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
105
+ lines.pop();
106
+ }
107
+ return lines.join('\n');
99
108
  }
100
109
  function getVisualSpeaker(message) {
101
110
  if (!message)
@@ -143,7 +152,7 @@ function buildClient(config) {
143
152
  if (baseURL) {
144
153
  clientOptions.baseURL = baseURL;
145
154
  }
146
- // Custom headers: parse PROTOAGENT_CUSTOM_HEADERS (newline-separated "Key: Value" pairs)
155
+ // Custom headers: env override takes precedence over provider defaults
147
156
  const rawHeaders = process.env.PROTOAGENT_CUSTOM_HEADERS?.trim();
148
157
  if (rawHeaders) {
149
158
  const defaultHeaders = {};
@@ -160,6 +169,9 @@ function buildClient(config) {
160
169
  clientOptions.defaultHeaders = defaultHeaders;
161
170
  }
162
171
  }
172
+ else if (provider?.headers && Object.keys(provider.headers).length > 0) {
173
+ clientOptions.defaultHeaders = provider.headers;
174
+ }
163
175
  return new OpenAI(clientOptions);
164
176
  }
165
177
  // ─── Sub-components ───
@@ -182,13 +194,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
182
194
  { label: sessionApprovalLabel, value: 'approve_session' },
183
195
  { label: 'Reject', value: 'reject' },
184
196
  ];
185
- 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) }) })] }));
197
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, marginY: 1, children: [_jsx(Text, { color: "green", 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) }) })] }));
186
198
  };
187
199
  /** Cost/usage display in the status bar. */
188
200
  const UsageDisplay = ({ usage, totalCost }) => {
189
201
  if (!usage && totalCost === 0)
190
202
  return null;
191
- 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)] }))] }));
203
+ return (_jsxs(Box, { marginTop: 1, 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)] }))] }));
192
204
  };
193
205
  /** Inline setup wizard — shown when no config exists. */
194
206
  const InlineSetup = ({ onComplete }) => {
@@ -196,7 +208,7 @@ const InlineSetup = ({ onComplete }) => {
196
208
  const [selectedProviderId, setSelectedProviderId] = useState('');
197
209
  const [selectedModelId, setSelectedModelId] = useState('');
198
210
  const [apiKeyError, setApiKeyError] = useState('');
199
- const providerItems = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
211
+ const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
200
212
  label: `${provider.name} - ${model.name}`,
201
213
  value: `${provider.id}:::${model.id}`,
202
214
  })));
@@ -209,15 +221,16 @@ const InlineSetup = ({ onComplete }) => {
209
221
  } }) })] }));
210
222
  }
211
223
  const provider = getProvider(selectedProviderId);
212
- 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) => {
213
- if (value.trim().length === 0) {
224
+ const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
225
+ 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: hasResolvedAuth ? 'Optional API key:' : 'Enter your API key:' }), apiKeyError && _jsx(Text, { color: "red", children: apiKeyError }), _jsx(PasswordInput, { placeholder: hasResolvedAuth ? 'Press enter to keep resolved auth' : `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
226
+ if (value.trim().length === 0 && !hasResolvedAuth) {
214
227
  setApiKeyError('API key cannot be empty.');
215
228
  return;
216
229
  }
217
230
  const newConfig = {
218
231
  provider: selectedProviderId,
219
232
  model: selectedModelId,
220
- apiKey: value.trim(),
233
+ ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
221
234
  };
222
235
  writeConfig(newConfig);
223
236
  onComplete(newConfig);
@@ -226,6 +239,7 @@ const InlineSetup = ({ onComplete }) => {
226
239
  // ─── Main App ───
227
240
  export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
228
241
  const { exit } = useApp();
242
+ const { stdout } = useStdout();
229
243
  // Core state
230
244
  const [config, setConfig] = useState(null);
231
245
  const [completionMessages, setCompletionMessages] = useState([]);
@@ -237,7 +251,10 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
237
251
  const [initialized, setInitialized] = useState(false);
238
252
  const [needsSetup, setNeedsSetup] = useState(false);
239
253
  const [logFilePath, setLogFilePath] = useState(null);
240
- // Collapsible statetrack which message indices are expanded
254
+ // Input reset key incremented on submit to force TextInput remount and clear
255
+ const [inputResetKey, setInputResetKey] = useState(0);
256
+ const [inputWidthKey, setInputWidthKey] = useState(stdout?.columns ?? 80);
257
+ // Collapsible state — only applies to live (current turn) messages
241
258
  const [expandedMessages, setExpandedMessages] = useState(new Set());
242
259
  const expandLatestMessage = useCallback((index) => {
243
260
  setExpandedMessages((prev) => {
@@ -254,6 +271,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
254
271
  const [lastUsage, setLastUsage] = useState(null);
255
272
  const [totalCost, setTotalCost] = useState(0);
256
273
  const [spinnerFrame, setSpinnerFrame] = useState(0);
274
+ // Active tool tracking — shows which tool is currently executing
275
+ const [activeTool, setActiveTool] = useState(null);
257
276
  // Session state
258
277
  const [session, setSession] = useState(null);
259
278
  // Quitting state — shows the resume command before exiting
@@ -264,6 +283,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
264
283
  const assistantMessageRef = useRef(null);
265
284
  // Abort controller for cancelling the current completion
266
285
  const abortControllerRef = useRef(null);
286
+ // Debounce timer for text_delta renders (~50ms batching)
287
+ const textFlushTimerRef = useRef(null);
267
288
  // ─── Post-config initialization (reused after inline setup) ───
268
289
  const initializeWithConfig = useCallback(async (loadedConfig) => {
269
290
  setConfig(loadedConfig);
@@ -308,6 +329,18 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
308
329
  }, 100);
309
330
  return () => clearInterval(interval);
310
331
  }, [loading]);
332
+ useEffect(() => {
333
+ if (!stdout)
334
+ return;
335
+ const handleResize = () => {
336
+ setInputWidthKey(stdout.columns ?? 80);
337
+ };
338
+ handleResize();
339
+ stdout.on('resize', handleResize);
340
+ return () => {
341
+ stdout.off('resize', handleResize);
342
+ };
343
+ }, [stdout]);
311
344
  useEffect(() => {
312
345
  const init = async () => {
313
346
  // Set log level and initialize log file
@@ -331,6 +364,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
331
364
  setPendingApproval({ request: req, resolve });
332
365
  });
333
366
  });
367
+ await loadRuntimeConfig();
334
368
  // Load config — if none exists, show inline setup
335
369
  const loadedConfig = readConfig();
336
370
  if (!loadedConfig) {
@@ -410,7 +444,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
410
444
  default:
411
445
  return false;
412
446
  }
413
- }, [exit, session, completionMessages]);
447
+ }, [config, exit, session, completionMessages]);
414
448
  // ─── Submit handler ───
415
449
  const handleSubmit = useCallback(async (value) => {
416
450
  const trimmed = value.trim();
@@ -421,10 +455,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
421
455
  const handled = await handleSlashCommand(trimmed);
422
456
  if (handled) {
423
457
  setInputText('');
458
+ setInputResetKey((prev) => prev + 1);
424
459
  return;
425
460
  }
426
461
  }
427
462
  setInputText('');
463
+ setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
428
464
  setLoading(true);
429
465
  setError(null);
430
466
  setHelpMessage(null);
@@ -436,14 +472,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
436
472
  assistantMessageRef.current = null;
437
473
  try {
438
474
  const pricing = getModelPricing(config.provider, config.model);
475
+ const requestDefaults = getRequestDefaultParams(config.provider, config.model);
439
476
  // Create abort controller for this completion
440
477
  abortControllerRef.current = new AbortController();
441
478
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
442
479
  switch (event.type) {
443
480
  case 'text_delta':
444
- // Update the current assistant message in completionMessages in real-time
481
+ // Update the current assistant message in completionMessages
445
482
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
446
- // First text delta - create the assistant message
483
+ // First text delta create the assistant message immediately
447
484
  const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
448
485
  setCompletionMessages((prev) => {
449
486
  assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
@@ -451,80 +488,96 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
451
488
  });
452
489
  }
453
490
  else {
454
- // Subsequent text delta - update the assistant message
491
+ // Subsequent deltas accumulate in ref, debounce the render (~50ms)
455
492
  assistantMessageRef.current.message.content += event.content || '';
456
- setCompletionMessages((prev) => {
457
- const updated = [...prev];
458
- updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
459
- return updated;
460
- });
493
+ if (!textFlushTimerRef.current) {
494
+ textFlushTimerRef.current = setTimeout(() => {
495
+ textFlushTimerRef.current = null;
496
+ setCompletionMessages((prev) => {
497
+ if (!assistantMessageRef.current)
498
+ return prev;
499
+ const updated = [...prev];
500
+ updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
501
+ return updated;
502
+ });
503
+ }, 50);
504
+ }
461
505
  }
462
506
  break;
463
507
  case 'tool_call':
464
508
  if (event.toolCall) {
465
509
  const toolCall = event.toolCall;
466
- setCompletionMessages((prev) => {
467
- const existingRef = assistantMessageRef.current;
468
- const existingMessage = existingRef?.message
469
- ? {
470
- ...existingRef.message,
471
- tool_calls: [...(existingRef.message.tool_calls || [])],
472
- }
473
- : null;
474
- const assistantMsg = existingMessage || {
475
- role: 'assistant',
476
- content: '',
477
- tool_calls: [],
478
- };
479
- const existingToolCallIndex = assistantMsg.tool_calls.findIndex((existingToolCall) => existingToolCall.id === toolCall.id);
480
- const nextToolCall = {
481
- id: toolCall.id,
482
- type: 'function',
483
- function: {
484
- name: toolCall.name,
485
- arguments: toolCall.args,
486
- },
487
- };
488
- if (existingToolCallIndex === -1) {
489
- assistantMsg.tool_calls.push(nextToolCall);
490
- }
491
- else {
492
- assistantMsg.tool_calls[existingToolCallIndex] = nextToolCall;
510
+ setActiveTool(toolCall.name);
511
+ // Track the tool call in the ref WITHOUT triggering a render.
512
+ // The render will happen when tool_result arrives.
513
+ const existingRef = assistantMessageRef.current;
514
+ const assistantMsg = existingRef?.message
515
+ ? {
516
+ ...existingRef.message,
517
+ tool_calls: [...(existingRef.message.tool_calls || [])],
493
518
  }
494
- const nextIndex = existingRef?.index ?? prev.length;
519
+ : { role: 'assistant', content: '', tool_calls: [] };
520
+ const nextToolCall = {
521
+ id: toolCall.id,
522
+ type: 'function',
523
+ function: { name: toolCall.name, arguments: toolCall.args },
524
+ };
525
+ const idx = assistantMsg.tool_calls.findIndex((tc) => tc.id === toolCall.id);
526
+ if (idx === -1) {
527
+ assistantMsg.tool_calls.push(nextToolCall);
528
+ }
529
+ else {
530
+ assistantMsg.tool_calls[idx] = nextToolCall;
531
+ }
532
+ if (!existingRef) {
533
+ // First tool call — we need to add the assistant message to state
534
+ setCompletionMessages((prev) => {
535
+ assistantMessageRef.current = {
536
+ message: assistantMsg,
537
+ index: prev.length,
538
+ kind: 'tool_call_assistant',
539
+ };
540
+ return [...prev, assistantMsg];
541
+ });
542
+ }
543
+ else {
544
+ // Subsequent tool calls — just update the ref, no render
495
545
  assistantMessageRef.current = {
546
+ ...existingRef,
496
547
  message: assistantMsg,
497
- index: nextIndex,
498
548
  kind: 'tool_call_assistant',
499
549
  };
500
- if (existingRef) {
501
- const updated = [...prev];
502
- updated[existingRef.index] = assistantMsg;
503
- return updated;
504
- }
505
- return [...prev, assistantMsg];
506
- });
550
+ }
507
551
  }
508
552
  break;
509
553
  case 'tool_result':
510
554
  if (event.toolCall) {
511
555
  const toolCall = event.toolCall;
556
+ setActiveTool(null);
512
557
  if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
513
558
  const currentAssistantIndex = assistantMessageRef.current?.index;
514
559
  if (typeof currentAssistantIndex === 'number') {
515
560
  expandLatestMessage(currentAssistantIndex);
516
561
  }
517
562
  }
518
- // Add tool result message to completion messages
519
- setCompletionMessages((prev) => [
520
- ...prev,
521
- {
563
+ // Flush the assistant message update + tool result in a SINGLE state update
564
+ setCompletionMessages((prev) => {
565
+ const updated = [...prev];
566
+ // Sync assistant message (may have new tool_calls since last render)
567
+ if (assistantMessageRef.current) {
568
+ updated[assistantMessageRef.current.index] = {
569
+ ...assistantMessageRef.current.message,
570
+ };
571
+ }
572
+ // Append tool result
573
+ updated.push({
522
574
  role: 'tool',
523
575
  tool_call_id: toolCall.id,
524
576
  content: toolCall.result || '',
525
577
  name: toolCall.name,
526
- },
527
- ]);
578
+ });
579
+ return updated;
580
+ });
528
581
  }
529
582
  break;
530
583
  case 'usage':
@@ -564,11 +617,25 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
564
617
  setError('Unknown error');
565
618
  }
566
619
  break;
620
+ case 'iteration_done':
621
+ assistantMessageRef.current = null;
622
+ break;
567
623
  case 'done':
624
+ // Clear any pending text delta timer
625
+ if (textFlushTimerRef.current) {
626
+ clearTimeout(textFlushTimerRef.current);
627
+ textFlushTimerRef.current = null;
628
+ }
629
+ setActiveTool(null);
568
630
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
569
631
  break;
570
632
  }
571
- }, { pricing: pricing || undefined, abortSignal: abortControllerRef.current.signal, sessionId: session?.id });
633
+ }, {
634
+ pricing: pricing || undefined,
635
+ abortSignal: abortControllerRef.current.signal,
636
+ sessionId: session?.id,
637
+ requestDefaults,
638
+ });
572
639
  // Final update to ensure we have the complete message history
573
640
  setCompletionMessages(updatedMessages);
574
641
  // Update session
@@ -605,18 +672,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
605
672
  : completionMessages.length;
606
673
  const archivedMessages = completionMessages.slice(0, liveStartIndex);
607
674
  const liveMessages = completionMessages.slice(liveStartIndex);
608
- const archivedMessageNodes = renderMessageList(archivedMessages, completionMessages, expandedMessages);
609
- const liveMessageNodes = renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex);
610
- 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) => {
675
+ const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
676
+ const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
677
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }), 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) => {
611
678
  initializeWithConfig(newConfig).catch((err) => {
612
679
  setError(`Initialization failed: ${err.message}`);
613
680
  });
614
- } })), _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 && ((() => {
681
+ } })), _jsxs(Box, { flexDirection: "column", flexGrow: 1, 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 && ((() => {
615
682
  const lastMsg = completionMessages[completionMessages.length - 1];
616
- // Show "Thinking..." only if the last message is a user message (no assistant response yet)
617
683
  return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
618
684
  })()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
619
685
  pendingApproval.resolve(response);
620
686
  setPendingApproval(null);
621
- } }))] }), _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] })] }))] }));
687
+ } }))] }), _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], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && (_jsx(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "green", bold: true, children: '>' }) }), _jsx(Box, { flexGrow: 1, minWidth: 10, children: _jsx(TextInput, { defaultValue: inputText, onChange: setInputText, placeholder: "Type your message... (/help for commands)", onSubmit: handleSubmit }, `${inputResetKey}-${inputWidthKey}`) })] }) }, `input-shell-${inputWidthKey}`)), 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] })] }))] }));
622
688
  };
@@ -219,6 +219,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
219
219
  const pricing = options.pricing;
220
220
  const abortSignal = options.abortSignal;
221
221
  const sessionId = options.sessionId;
222
+ const requestDefaults = options.requestDefaults || {};
222
223
  // Note: userInput is passed for context/logging but user message should already be in messages array
223
224
  // (added by the caller in handleSubmit for immediate UI display)
224
225
  const updatedMessages = [...messages];
@@ -243,7 +244,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
243
244
  if (pricing) {
244
245
  const contextInfo = getContextInfo(updatedMessages, pricing);
245
246
  if (contextInfo.needsCompaction) {
246
- const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens);
247
+ const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens, requestDefaults, sessionId);
247
248
  // Replace messages in-place
248
249
  updatedMessages.length = 0;
249
250
  updatedMessages.push(...compacted);
@@ -287,6 +288,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
287
288
  }
288
289
  }
289
290
  const stream = await client.chat.completions.create({
291
+ ...requestDefaults,
290
292
  model,
291
293
  messages: updatedMessages,
292
294
  tools: allTools,
@@ -349,20 +351,19 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
349
351
  }
350
352
  }
351
353
  }
352
- // Emit usage info
353
- if (pricing) {
354
+ // Emit usage info — always emit, even without pricing (use estimates)
355
+ {
354
356
  const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
355
357
  const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
356
- const usageInfo = createUsageInfo(inputTokens, outputTokens, pricing);
357
- const contextInfo = getContextInfo(updatedMessages, pricing);
358
+ const cost = pricing
359
+ ? createUsageInfo(inputTokens, outputTokens, pricing).estimatedCost
360
+ : 0;
361
+ const contextPercent = pricing
362
+ ? getContextInfo(updatedMessages, pricing).utilizationPercentage
363
+ : 0;
358
364
  onEvent({
359
365
  type: 'usage',
360
- usage: {
361
- inputTokens,
362
- outputTokens,
363
- cost: usageInfo.estimatedCost,
364
- contextPercent: contextInfo.utilizationPercentage,
365
- },
366
+ usage: { inputTokens, outputTokens, cost, contextPercent },
366
367
  });
367
368
  }
368
369
  // Handle tool calls
@@ -389,6 +390,12 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
389
390
  });
390
391
  updatedMessages.push(assistantMessage);
391
392
  for (const toolCall of assistantMessage.tool_calls) {
393
+ // Check abort between tool calls
394
+ if (abortSignal?.aborted) {
395
+ logger.debug('Agentic loop aborted between tool calls');
396
+ emitAbortAndFinish(onEvent);
397
+ return updatedMessages;
398
+ }
392
399
  const { name, arguments: argsStr } = toolCall.function;
393
400
  onEvent({
394
401
  type: 'tool_call',
@@ -399,7 +406,18 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
399
406
  let result;
400
407
  // Handle sub-agent tool specially
401
408
  if (name === 'sub_agent') {
402
- result = await runSubAgent(client, model, args.task, args.max_iterations);
409
+ const subProgress = (evt) => {
410
+ onEvent({
411
+ type: 'tool_call',
412
+ toolCall: {
413
+ id: toolCall.id,
414
+ name: `sub_agent → ${evt.tool}`,
415
+ args: '',
416
+ status: evt.status === 'running' ? 'running' : 'done',
417
+ },
418
+ });
419
+ };
420
+ result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
403
421
  }
404
422
  else {
405
423
  result = await handleToolCall(name, args, { sessionId });
@@ -433,6 +451,9 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
433
451
  });
434
452
  }
435
453
  }
454
+ // Signal UI that this iteration's tool calls are all done,
455
+ // so it can flush completed messages to static output.
456
+ onEvent({ type: 'iteration_done' });
436
457
  // Continue loop — let the LLM process tool results
437
458
  continue;
438
459
  }
@@ -7,7 +7,7 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
7
7
  const isLong = isTooManyLines || isTooManyChars;
8
8
  // If content is short, always show it
9
9
  if (!isLong) {
10
- return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom, borderStyle: "round", borderColor: titleColor || 'white', children: [_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }) }), _jsx(Box, { marginLeft: 2, paddingRight: 1, children: _jsx(Text, { dimColor: dimColor, children: content }) })] }));
10
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: dimColor, children: content }) })] }));
11
11
  }
12
12
  // For long content, show preview or full content
13
13
  let preview;
@@ -22,5 +22,5 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
22
22
  : linesTruncated;
23
23
  }
24
24
  const hasMore = !expanded;
25
- return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom, borderStyle: "round", borderColor: titleColor || 'white', children: [_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, paddingRight: 1, children: [_jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] })] }));
25
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] })] }));
26
26
  };
@@ -7,12 +7,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  import { useState } from 'react';
8
8
  import { Box, Text } from 'ink';
9
9
  import { PasswordInput, Select } from '@inkjs/ui';
10
- import { getProvider, SUPPORTED_MODELS } from '../providers.js';
10
+ import { getAllProviders, getProvider } from '../providers.js';
11
+ import { resolveApiKey } from '../config.js';
11
12
  export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
12
13
  const [step, setStep] = useState('select_provider');
13
14
  const [selectedProviderId, setSelectedProviderId] = useState(currentConfig.provider);
14
15
  const [selectedModelId, setSelectedModelId] = useState(currentConfig.model);
15
- const providerItems = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
16
+ const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
16
17
  label: `${provider.name} - ${model.name}`,
17
18
  value: `${provider.id}:::${model.id}`,
18
19
  })));
@@ -28,12 +29,13 @@ export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
28
29
  }
29
30
  // API key entry step
30
31
  const provider = getProvider(selectedProviderId);
31
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: "Enter your API key (or leave empty to keep current/env var):" }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
32
+ const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
33
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key (leave empty to keep resolved auth):' : 'Enter your API key:' }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
32
34
  const finalApiKey = value.trim().length > 0 ? value.trim() : currentConfig.apiKey;
33
35
  const newConfig = {
34
36
  provider: selectedProviderId,
35
37
  model: selectedModelId,
36
- apiKey: finalApiKey,
38
+ ...(finalApiKey?.trim() ? { apiKey: finalApiKey.trim() } : {}),
37
39
  };
38
40
  onComplete(newConfig);
39
41
  } })] }));