protoagent 0.1.3 → 0.1.5

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 CHANGED
@@ -1,4 +1,7 @@
1
- # ProtoAgent
1
+ ```
2
+ █▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀
3
+ █▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █
4
+ ```
2
5
 
3
6
  A minimal, educational AI coding agent CLI written in TypeScript. It stays small enough to read in an afternoon, but it still has the core pieces you expect from a real coding agent: a streaming tool-use loop, approvals, sessions, MCP, skills, sub-agents, and cost tracking.
4
7
 
@@ -8,7 +11,7 @@ A minimal, educational AI coding agent CLI written in TypeScript. It stays small
8
11
  - **Built-in tools** — Read, write, edit, list, search, run shell commands, manage todos, and fetch web pages with `webfetch`
9
12
  - **Approval system** — Inline confirmation for file writes, file edits, and non-safe shell commands
10
13
  - **Session persistence** — Conversations and TODO state are saved automatically and can be resumed with `--session`
11
- - **Dynamic extensions** — Load skills on demand and add external tools through MCP servers
14
+ <!-- - **Dynamic extensions** — Load skills on demand and add external tools through MCP servers -->
12
15
  - **Sub-agents** — Delegate self-contained tasks to isolated child conversations
13
16
  - **Usage tracking** — Live token, context, and estimated cost display in the TUI
14
17
 
@@ -19,7 +22,12 @@ npm install -g protoagent
19
22
  protoagent
20
23
  ```
21
24
 
22
- On first run, ProtoAgent shows an inline setup flow where you pick a provider/model pair and enter an API key. Config is stored in `~/.local/share/protoagent/config.json` on macOS/Linux and `~/AppData/Local/protoagent/config.json` on Windows.
25
+ On first run, ProtoAgent shows an inline setup flow where you pick a provider/model pair and enter an API key. ProtoAgent stores that selection in `protoagent.jsonc`.
26
+
27
+ Runtime config lookup is simple:
28
+
29
+ - if `<cwd>/.protoagent/protoagent.jsonc` exists, ProtoAgent uses it
30
+ - otherwise it falls back to the shared user config at `~/.config/protoagent/protoagent.jsonc` on macOS/Linux and `~/AppData/Local/protoagent/protoagent.jsonc` on Windows
23
31
 
24
32
  You can also run the standalone wizard directly:
25
33
 
@@ -27,6 +35,29 @@ You can also run the standalone wizard directly:
27
35
  protoagent configure
28
36
  ```
29
37
 
38
+ Or configure a specific target non-interactively:
39
+
40
+ ```bash
41
+ protoagent configure --project --provider openai --model gpt-5-mini
42
+ protoagent configure --user --provider anthropic --model claude-sonnet-4-6
43
+ ```
44
+
45
+ To create a runtime config file for the current project or your shared user config, run:
46
+
47
+ ```bash
48
+ protoagent init
49
+ ```
50
+
51
+ `protoagent init` creates `protoagent.jsonc` in either `<cwd>/.protoagent/protoagent.jsonc` or your shared user config location and prints the exact path it used.
52
+
53
+ For scripts or non-interactive setup, use:
54
+
55
+ ```bash
56
+ protoagent init --project
57
+ protoagent init --user
58
+ protoagent init --project --force
59
+ ```
60
+
30
61
  ## Interactive Commands
31
62
 
32
63
  - `/help` — Show available slash commands
package/dist/App.js CHANGED
@@ -6,8 +6,8 @@ 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';
@@ -24,7 +24,7 @@ import { generateSystemPrompt } from './system-prompt.js';
24
24
  import { CollapsibleBox } from './components/CollapsibleBox.js';
25
25
  import { ConsolidatedToolMessage } from './components/ConsolidatedToolMessage.js';
26
26
  import { FormattedMessage } from './components/FormattedMessage.js';
27
- function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0) {
27
+ function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0, deferTables = false) {
28
28
  const rendered = [];
29
29
  const skippedIndices = new Set();
30
30
  messagesToRender.forEach((msg, localIndex) => {
@@ -35,6 +35,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
35
35
  const msgAny = msg;
36
36
  const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
37
37
  const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
38
+ const normalizedContent = normalizeMessageSpacing(displayContent || '');
38
39
  const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
39
40
  const previousMessage = index > 0 ? allMessages[index - 1] : null;
40
41
  const followsToolMessage = previousMessage?.role === 'tool';
@@ -43,12 +44,15 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
43
44
  const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
44
45
  const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
45
46
  const speakerChanged = previousSpeaker !== currentSpeaker;
46
- 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) {
47
54
  rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
48
55
  }
49
- if (isConversationTurn && previousWasConversationTurn && speakerChanged) {
50
- rendered.push(_jsx(Text, { children: " " }, `turn-spacer-${index}`));
51
- }
52
56
  if (msg.role === 'user') {
53
57
  rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
54
58
  return;
@@ -58,8 +62,8 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
58
62
  return;
59
63
  }
60
64
  if (isToolCall) {
61
- if (displayContent && displayContent.trim().length > 0) {
62
- 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`));
63
67
  }
64
68
  const toolCalls = msgAny.tool_calls.map((tc) => ({
65
69
  id: tc.id,
@@ -72,7 +76,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
72
76
  const nextMsg = messagesToRender[nextLocalIndex];
73
77
  if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
74
78
  toolResults.set(toolCall.id, {
75
- content: nextMsg.content || '',
79
+ content: normalizeMessageSpacing(nextMsg.content || ''),
76
80
  name: nextMsg.name || toolCall.name,
77
81
  });
78
82
  skippedIndices.add(nextLocalIndex);
@@ -84,19 +88,23 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
84
88
  return;
85
89
  }
86
90
  if (msg.role === 'tool') {
87
- 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));
88
92
  return;
89
93
  }
90
- 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));
91
95
  });
92
96
  return rendered;
93
97
  }
94
- function trimAssistantSpacing(message, trimMode = 'both') {
95
- if (trimMode === 'start')
96
- return message.trimStart();
97
- if (trimMode === 'end')
98
- return message.trimEnd();
99
- 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');
100
108
  }
101
109
  function getVisualSpeaker(message) {
102
110
  if (!message)
@@ -186,13 +194,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
186
194
  { label: sessionApprovalLabel, value: 'approve_session' },
187
195
  { label: 'Reject', value: 'reject' },
188
196
  ];
189
- 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) }) })] }));
190
198
  };
191
199
  /** Cost/usage display in the status bar. */
192
200
  const UsageDisplay = ({ usage, totalCost }) => {
193
201
  if (!usage && totalCost === 0)
194
202
  return null;
195
- 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)] }))] }));
196
204
  };
197
205
  /** Inline setup wizard — shown when no config exists. */
198
206
  const InlineSetup = ({ onComplete }) => {
@@ -224,13 +232,14 @@ const InlineSetup = ({ onComplete }) => {
224
232
  model: selectedModelId,
225
233
  ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
226
234
  };
227
- writeConfig(newConfig);
235
+ writeConfig(newConfig, 'project');
228
236
  onComplete(newConfig);
229
237
  } })] }));
230
238
  };
231
239
  // ─── Main App ───
232
240
  export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
233
241
  const { exit } = useApp();
242
+ const { stdout } = useStdout();
234
243
  // Core state
235
244
  const [config, setConfig] = useState(null);
236
245
  const [completionMessages, setCompletionMessages] = useState([]);
@@ -242,7 +251,10 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
242
251
  const [initialized, setInitialized] = useState(false);
243
252
  const [needsSetup, setNeedsSetup] = useState(false);
244
253
  const [logFilePath, setLogFilePath] = useState(null);
245
- // 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
246
258
  const [expandedMessages, setExpandedMessages] = useState(new Set());
247
259
  const expandLatestMessage = useCallback((index) => {
248
260
  setExpandedMessages((prev) => {
@@ -259,6 +271,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
259
271
  const [lastUsage, setLastUsage] = useState(null);
260
272
  const [totalCost, setTotalCost] = useState(0);
261
273
  const [spinnerFrame, setSpinnerFrame] = useState(0);
274
+ // Active tool tracking — shows which tool is currently executing
275
+ const [activeTool, setActiveTool] = useState(null);
262
276
  // Session state
263
277
  const [session, setSession] = useState(null);
264
278
  // Quitting state — shows the resume command before exiting
@@ -269,6 +283,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
269
283
  const assistantMessageRef = useRef(null);
270
284
  // Abort controller for cancelling the current completion
271
285
  const abortControllerRef = useRef(null);
286
+ // Debounce timer for text_delta renders (~50ms batching)
287
+ const textFlushTimerRef = useRef(null);
272
288
  // ─── Post-config initialization (reused after inline setup) ───
273
289
  const initializeWithConfig = useCallback(async (loadedConfig) => {
274
290
  setConfig(loadedConfig);
@@ -313,6 +329,18 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
313
329
  }, 100);
314
330
  return () => clearInterval(interval);
315
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]);
316
344
  useEffect(() => {
317
345
  const init = async () => {
318
346
  // Set log level and initialize log file
@@ -337,8 +365,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
337
365
  });
338
366
  });
339
367
  await loadRuntimeConfig();
340
- // Load config — if none exists, show inline setup
341
- const loadedConfig = readConfig();
368
+ const loadedConfig = readConfig('active');
342
369
  if (!loadedConfig) {
343
370
  setNeedsSetup(true);
344
371
  return;
@@ -416,7 +443,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
416
443
  default:
417
444
  return false;
418
445
  }
419
- }, [exit, session, completionMessages]);
446
+ }, [config, exit, session, completionMessages]);
420
447
  // ─── Submit handler ───
421
448
  const handleSubmit = useCallback(async (value) => {
422
449
  const trimmed = value.trim();
@@ -427,10 +454,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
427
454
  const handled = await handleSlashCommand(trimmed);
428
455
  if (handled) {
429
456
  setInputText('');
457
+ setInputResetKey((prev) => prev + 1);
430
458
  return;
431
459
  }
432
460
  }
433
461
  setInputText('');
462
+ setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
434
463
  setLoading(true);
435
464
  setError(null);
436
465
  setHelpMessage(null);
@@ -448,9 +477,9 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
448
477
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
449
478
  switch (event.type) {
450
479
  case 'text_delta':
451
- // Update the current assistant message in completionMessages in real-time
480
+ // Update the current assistant message in completionMessages
452
481
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
453
- // First text delta - create the assistant message
482
+ // First text delta create the assistant message immediately
454
483
  const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
455
484
  setCompletionMessages((prev) => {
456
485
  assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
@@ -458,80 +487,96 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
458
487
  });
459
488
  }
460
489
  else {
461
- // Subsequent text delta - update the assistant message
490
+ // Subsequent deltas accumulate in ref, debounce the render (~50ms)
462
491
  assistantMessageRef.current.message.content += event.content || '';
463
- setCompletionMessages((prev) => {
464
- const updated = [...prev];
465
- updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
466
- return updated;
467
- });
492
+ if (!textFlushTimerRef.current) {
493
+ textFlushTimerRef.current = setTimeout(() => {
494
+ textFlushTimerRef.current = null;
495
+ setCompletionMessages((prev) => {
496
+ if (!assistantMessageRef.current)
497
+ return prev;
498
+ const updated = [...prev];
499
+ updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
500
+ return updated;
501
+ });
502
+ }, 50);
503
+ }
468
504
  }
469
505
  break;
470
506
  case 'tool_call':
471
507
  if (event.toolCall) {
472
508
  const toolCall = event.toolCall;
473
- setCompletionMessages((prev) => {
474
- const existingRef = assistantMessageRef.current;
475
- const existingMessage = existingRef?.message
476
- ? {
477
- ...existingRef.message,
478
- tool_calls: [...(existingRef.message.tool_calls || [])],
479
- }
480
- : null;
481
- const assistantMsg = existingMessage || {
482
- role: 'assistant',
483
- content: '',
484
- tool_calls: [],
485
- };
486
- const existingToolCallIndex = assistantMsg.tool_calls.findIndex((existingToolCall) => existingToolCall.id === toolCall.id);
487
- const nextToolCall = {
488
- id: toolCall.id,
489
- type: 'function',
490
- function: {
491
- name: toolCall.name,
492
- arguments: toolCall.args,
493
- },
494
- };
495
- if (existingToolCallIndex === -1) {
496
- assistantMsg.tool_calls.push(nextToolCall);
497
- }
498
- else {
499
- assistantMsg.tool_calls[existingToolCallIndex] = nextToolCall;
509
+ setActiveTool(toolCall.name);
510
+ // Track the tool call in the ref WITHOUT triggering a render.
511
+ // The render will happen when tool_result arrives.
512
+ const existingRef = assistantMessageRef.current;
513
+ const assistantMsg = existingRef?.message
514
+ ? {
515
+ ...existingRef.message,
516
+ tool_calls: [...(existingRef.message.tool_calls || [])],
500
517
  }
501
- const nextIndex = existingRef?.index ?? prev.length;
518
+ : { role: 'assistant', content: '', tool_calls: [] };
519
+ const nextToolCall = {
520
+ id: toolCall.id,
521
+ type: 'function',
522
+ function: { name: toolCall.name, arguments: toolCall.args },
523
+ };
524
+ const idx = assistantMsg.tool_calls.findIndex((tc) => tc.id === toolCall.id);
525
+ if (idx === -1) {
526
+ assistantMsg.tool_calls.push(nextToolCall);
527
+ }
528
+ else {
529
+ assistantMsg.tool_calls[idx] = nextToolCall;
530
+ }
531
+ if (!existingRef) {
532
+ // First tool call — we need to add the assistant message to state
533
+ setCompletionMessages((prev) => {
534
+ assistantMessageRef.current = {
535
+ message: assistantMsg,
536
+ index: prev.length,
537
+ kind: 'tool_call_assistant',
538
+ };
539
+ return [...prev, assistantMsg];
540
+ });
541
+ }
542
+ else {
543
+ // Subsequent tool calls — just update the ref, no render
502
544
  assistantMessageRef.current = {
545
+ ...existingRef,
503
546
  message: assistantMsg,
504
- index: nextIndex,
505
547
  kind: 'tool_call_assistant',
506
548
  };
507
- if (existingRef) {
508
- const updated = [...prev];
509
- updated[existingRef.index] = assistantMsg;
510
- return updated;
511
- }
512
- return [...prev, assistantMsg];
513
- });
549
+ }
514
550
  }
515
551
  break;
516
552
  case 'tool_result':
517
553
  if (event.toolCall) {
518
554
  const toolCall = event.toolCall;
555
+ setActiveTool(null);
519
556
  if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
520
557
  const currentAssistantIndex = assistantMessageRef.current?.index;
521
558
  if (typeof currentAssistantIndex === 'number') {
522
559
  expandLatestMessage(currentAssistantIndex);
523
560
  }
524
561
  }
525
- // Add tool result message to completion messages
526
- setCompletionMessages((prev) => [
527
- ...prev,
528
- {
562
+ // Flush the assistant message update + tool result in a SINGLE state update
563
+ setCompletionMessages((prev) => {
564
+ const updated = [...prev];
565
+ // Sync assistant message (may have new tool_calls since last render)
566
+ if (assistantMessageRef.current) {
567
+ updated[assistantMessageRef.current.index] = {
568
+ ...assistantMessageRef.current.message,
569
+ };
570
+ }
571
+ // Append tool result
572
+ updated.push({
529
573
  role: 'tool',
530
574
  tool_call_id: toolCall.id,
531
575
  content: toolCall.result || '',
532
576
  name: toolCall.name,
533
- },
534
- ]);
577
+ });
578
+ return updated;
579
+ });
535
580
  }
536
581
  break;
537
582
  case 'usage':
@@ -571,7 +616,16 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
571
616
  setError('Unknown error');
572
617
  }
573
618
  break;
619
+ case 'iteration_done':
620
+ assistantMessageRef.current = null;
621
+ break;
574
622
  case 'done':
623
+ // Clear any pending text delta timer
624
+ if (textFlushTimerRef.current) {
625
+ clearTimeout(textFlushTimerRef.current);
626
+ textFlushTimerRef.current = null;
627
+ }
628
+ setActiveTool(null);
575
629
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
576
630
  break;
577
631
  }
@@ -617,18 +671,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
617
671
  : completionMessages.length;
618
672
  const archivedMessages = completionMessages.slice(0, liveStartIndex);
619
673
  const liveMessages = completionMessages.slice(liveStartIndex);
620
- const archivedMessageNodes = renderMessageList(archivedMessages, completionMessages, expandedMessages);
621
- const liveMessageNodes = renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex);
622
- 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) => {
674
+ const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
675
+ const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
676
+ 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) => {
623
677
  initializeWithConfig(newConfig).catch((err) => {
624
678
  setError(`Initialization failed: ${err.message}`);
625
679
  });
626
- } })), _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 && ((() => {
680
+ } })), _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 && ((() => {
627
681
  const lastMsg = completionMessages[completionMessages.length - 1];
628
- // Show "Thinking..." only if the last message is a user message (no assistant response yet)
629
682
  return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
630
683
  })()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
631
684
  pendingApproval.resolve(response);
632
685
  setPendingApproval(null);
633
- } }))] }), _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] })] }))] }));
686
+ } }))] }), _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] })] }))] }));
634
687
  };
@@ -351,20 +351,19 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
351
351
  }
352
352
  }
353
353
  }
354
- // Emit usage info
355
- if (pricing) {
354
+ // Emit usage info — always emit, even without pricing (use estimates)
355
+ {
356
356
  const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
357
357
  const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
358
- const usageInfo = createUsageInfo(inputTokens, outputTokens, pricing);
359
- 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;
360
364
  onEvent({
361
365
  type: 'usage',
362
- usage: {
363
- inputTokens,
364
- outputTokens,
365
- cost: usageInfo.estimatedCost,
366
- contextPercent: contextInfo.utilizationPercentage,
367
- },
366
+ usage: { inputTokens, outputTokens, cost, contextPercent },
368
367
  });
369
368
  }
370
369
  // Handle tool calls
@@ -391,6 +390,12 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
391
390
  });
392
391
  updatedMessages.push(assistantMessage);
393
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
+ }
394
399
  const { name, arguments: argsStr } = toolCall.function;
395
400
  onEvent({
396
401
  type: 'tool_call',
@@ -401,10 +406,21 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
401
406
  let result;
402
407
  // Handle sub-agent tool specially
403
408
  if (name === 'sub_agent') {
404
- result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults);
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);
405
421
  }
406
422
  else {
407
- result = await handleToolCall(name, args, { sessionId });
423
+ result = await handleToolCall(name, args, { sessionId, abortSignal });
408
424
  }
409
425
  logger.debug('Tool result', {
410
426
  tool: name,
@@ -435,6 +451,9 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
435
451
  });
436
452
  }
437
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' });
438
457
  // Continue loop — let the LLM process tool results
439
458
  continue;
440
459
  }
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { readFileSync } from 'node:fs';
13
13
  import { render } from 'ink';
14
14
  import { Command } from 'commander';
15
15
  import { App } from './App.js';
16
- import { ConfigureComponent } from './config.js';
16
+ import { ConfigureComponent, InitComponent, readConfig, writeConfig, writeInitConfig } from './config.js';
17
17
  // Get package.json version
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
@@ -33,7 +33,64 @@ program
33
33
  program
34
34
  .command('configure')
35
35
  .description('Configure AI model and API key settings')
36
- .action(() => {
36
+ .option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
37
+ .option('--user', 'Write the shared user protoagent.jsonc')
38
+ .option('--provider <id>', 'Provider id to configure')
39
+ .option('--model <id>', 'Model id to configure')
40
+ .option('--api-key <key>', 'Explicit API key to store in protoagent.jsonc')
41
+ .action((options) => {
42
+ if (options.project || options.user || options.provider || options.model || options.apiKey) {
43
+ if (options.project && options.user) {
44
+ console.error('Choose only one of --project or --user.');
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ if (!options.provider || !options.model) {
49
+ console.error('Non-interactive configure requires --provider and --model.');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const target = options.project ? 'project' : 'user';
54
+ const resultPath = writeConfig({
55
+ provider: options.provider,
56
+ model: options.model,
57
+ ...(typeof options.apiKey === 'string' && options.apiKey.trim() ? { apiKey: options.apiKey.trim() } : {}),
58
+ }, target);
59
+ console.log('Configured ProtoAgent:');
60
+ console.log(resultPath);
61
+ const selected = readConfig(target);
62
+ if (selected) {
63
+ console.log(`${selected.provider} / ${selected.model}`);
64
+ }
65
+ return;
66
+ }
37
67
  render(_jsx(ConfigureComponent, {}));
38
68
  });
69
+ program
70
+ .command('init')
71
+ .description('Create a project-local or shared ProtoAgent runtime config')
72
+ .option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
73
+ .option('--user', 'Write the shared user protoagent.jsonc')
74
+ .option('--force', 'Overwrite an existing target file')
75
+ .action((options) => {
76
+ if (options.project || options.user) {
77
+ if (options.project && options.user) {
78
+ console.error('Choose only one of --project or --user.');
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ const result = writeInitConfig(options.project ? 'project' : 'user', process.cwd(), {
83
+ overwrite: Boolean(options.force),
84
+ });
85
+ const message = result.status === 'created'
86
+ ? 'Created ProtoAgent config:'
87
+ : result.status === 'overwritten'
88
+ ? 'Overwrote ProtoAgent config:'
89
+ : 'ProtoAgent config already exists:';
90
+ console.log(message);
91
+ console.log(result.path);
92
+ return;
93
+ }
94
+ render(_jsx(InitComponent, {}));
95
+ });
39
96
  program.parse(process.argv);