protoagent 0.1.3 → 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,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 }) => {
@@ -231,6 +239,7 @@ const InlineSetup = ({ onComplete }) => {
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
@@ -416,7 +444,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
416
444
  default:
417
445
  return false;
418
446
  }
419
- }, [exit, session, completionMessages]);
447
+ }, [config, exit, session, completionMessages]);
420
448
  // ─── Submit handler ───
421
449
  const handleSubmit = useCallback(async (value) => {
422
450
  const trimmed = value.trim();
@@ -427,10 +455,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
427
455
  const handled = await handleSlashCommand(trimmed);
428
456
  if (handled) {
429
457
  setInputText('');
458
+ setInputResetKey((prev) => prev + 1);
430
459
  return;
431
460
  }
432
461
  }
433
462
  setInputText('');
463
+ setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
434
464
  setLoading(true);
435
465
  setError(null);
436
466
  setHelpMessage(null);
@@ -448,9 +478,9 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
448
478
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
449
479
  switch (event.type) {
450
480
  case 'text_delta':
451
- // Update the current assistant message in completionMessages in real-time
481
+ // Update the current assistant message in completionMessages
452
482
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
453
- // First text delta - create the assistant message
483
+ // First text delta create the assistant message immediately
454
484
  const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
455
485
  setCompletionMessages((prev) => {
456
486
  assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
@@ -458,80 +488,96 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
458
488
  });
459
489
  }
460
490
  else {
461
- // Subsequent text delta - update the assistant message
491
+ // Subsequent deltas accumulate in ref, debounce the render (~50ms)
462
492
  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
- });
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
+ }
468
505
  }
469
506
  break;
470
507
  case 'tool_call':
471
508
  if (event.toolCall) {
472
509
  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;
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 || [])],
500
518
  }
501
- 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
502
545
  assistantMessageRef.current = {
546
+ ...existingRef,
503
547
  message: assistantMsg,
504
- index: nextIndex,
505
548
  kind: 'tool_call_assistant',
506
549
  };
507
- if (existingRef) {
508
- const updated = [...prev];
509
- updated[existingRef.index] = assistantMsg;
510
- return updated;
511
- }
512
- return [...prev, assistantMsg];
513
- });
550
+ }
514
551
  }
515
552
  break;
516
553
  case 'tool_result':
517
554
  if (event.toolCall) {
518
555
  const toolCall = event.toolCall;
556
+ setActiveTool(null);
519
557
  if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
520
558
  const currentAssistantIndex = assistantMessageRef.current?.index;
521
559
  if (typeof currentAssistantIndex === 'number') {
522
560
  expandLatestMessage(currentAssistantIndex);
523
561
  }
524
562
  }
525
- // Add tool result message to completion messages
526
- setCompletionMessages((prev) => [
527
- ...prev,
528
- {
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({
529
574
  role: 'tool',
530
575
  tool_call_id: toolCall.id,
531
576
  content: toolCall.result || '',
532
577
  name: toolCall.name,
533
- },
534
- ]);
578
+ });
579
+ return updated;
580
+ });
535
581
  }
536
582
  break;
537
583
  case 'usage':
@@ -571,7 +617,16 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
571
617
  setError('Unknown error');
572
618
  }
573
619
  break;
620
+ case 'iteration_done':
621
+ assistantMessageRef.current = null;
622
+ break;
574
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);
575
630
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
576
631
  break;
577
632
  }
@@ -617,18 +672,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
617
672
  : completionMessages.length;
618
673
  const archivedMessages = completionMessages.slice(0, liveStartIndex);
619
674
  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) => {
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) => {
623
678
  initializeWithConfig(newConfig).catch((err) => {
624
679
  setError(`Initialization failed: ${err.message}`);
625
680
  });
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 && ((() => {
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 && ((() => {
627
682
  const lastMsg = completionMessages[completionMessages.length - 1];
628
- // Show "Thinking..." only if the last message is a user message (no assistant response yet)
629
683
  return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
630
684
  })()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
631
685
  pendingApproval.resolve(response);
632
686
  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] })] }))] }));
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] })] }))] }));
634
688
  };
@@ -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,7 +406,18 @@ 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
423
  result = await handleToolCall(name, args, { sessionId });
@@ -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
  }
@@ -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
  };
@@ -1,6 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { Table } from './Table.js';
4
3
  import { FormattedMessage } from './FormattedMessage.js';
5
4
  export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = false, }) => {
6
5
  const toolNames = toolCalls.map((toolCall) => toolCall.name);
@@ -9,18 +8,11 @@ export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = fal
9
8
  const titleColor = containsTodoTool ? 'green' : 'white';
10
9
  const isExpanded = expanded || containsTodoTool;
11
10
  if (isExpanded) {
12
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "white", children: [_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }) }), _jsx(Box, { flexDirection: "column", marginLeft: 2, paddingRight: 1, children: toolCalls.map((toolCall, idx) => {
11
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }), _jsx(Box, { flexDirection: "column", marginLeft: 1, children: toolCalls.map((toolCall, idx) => {
13
12
  const result = toolResults.get(toolCall.id);
14
13
  if (!result)
15
14
  return null;
16
- // Try to see if it's JSON that could be a table
17
- let isJsonTable = false;
18
- try {
19
- const parsed = JSON.parse(result.content);
20
- isJsonTable = typeof parsed === 'object' && parsed !== null;
21
- }
22
- catch (e) { }
23
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), isJsonTable ? (_jsx(Table, { data: result.content })) : (_jsx(FormattedMessage, { content: result.content }))] }, idx));
15
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
24
16
  }) })] }));
25
17
  }
26
18
  const compactLines = toolCalls.flatMap((toolCall) => {
@@ -37,5 +29,5 @@ export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = fal
37
29
  const preview = compactPreview.length > previewLimit
38
30
  ? `${compactPreview.slice(0, previewLimit).trimEnd()}... (use /expand)`
39
31
  : compactPreview;
40
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: titleColor || 'white', children: [_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }) }), _jsx(Box, { marginLeft: 2, paddingRight: 1, children: _jsx(Text, { dimColor: true, children: preview }) })] }));
32
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: preview }) })] }));
41
33
  };
@@ -1,16 +1,89 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { Table } from './Table.js';
4
3
  import { formatMessage } from '../utils/format-message.js';
4
+ export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
5
+ const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
6
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
7
+ : null;
8
+ const COMBINING_MARK_PATTERN = /\p{Mark}/u;
9
+ const ZERO_WIDTH_PATTERN = /[\u200B-\u200D\uFE0E\uFE0F]/u;
10
+ const DOUBLE_WIDTH_PATTERN = /[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{1F900}-\u{1F9FF}\u{1F1E6}-\u{1F1FF}]/u;
11
+ function splitGraphemes(text) {
12
+ if (!text)
13
+ return [];
14
+ if (graphemeSegmenter) {
15
+ return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment);
16
+ }
17
+ return Array.from(text);
18
+ }
19
+ function getGraphemeWidth(grapheme) {
20
+ if (!grapheme)
21
+ return 0;
22
+ if (ZERO_WIDTH_PATTERN.test(grapheme))
23
+ return 0;
24
+ if (COMBINING_MARK_PATTERN.test(grapheme))
25
+ return 0;
26
+ if (/^[\u0000-\u001F\u007F-\u009F]$/.test(grapheme))
27
+ return 0;
28
+ if (DOUBLE_WIDTH_PATTERN.test(grapheme))
29
+ return 2;
30
+ return 1;
31
+ }
32
+ function getTextWidth(text) {
33
+ return splitGraphemes(text).reduce((width, grapheme) => width + getGraphemeWidth(grapheme), 0);
34
+ }
35
+ function padToWidth(text, width) {
36
+ const padding = Math.max(0, width - getTextWidth(text));
37
+ return text + ' '.repeat(padding);
38
+ }
39
+ function parseMarkdownTableToRows(markdown) {
40
+ const lines = markdown.trim().split('\n');
41
+ if (lines.length < 3)
42
+ return null;
43
+ const parseRow = (row) => row.split('|')
44
+ .map((cell) => cell.trim())
45
+ .filter((cell, index, array) => {
46
+ if (index === 0 && cell === '')
47
+ return false;
48
+ if (index === array.length - 1 && cell === '')
49
+ return false;
50
+ return true;
51
+ });
52
+ const header = parseRow(lines[0]);
53
+ const separator = parseRow(lines[1]);
54
+ if (header.length === 0 || separator.length === 0)
55
+ return null;
56
+ if (!separator.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, ''))))
57
+ return null;
58
+ const rows = lines.slice(2).map(parseRow);
59
+ return [header, ...rows];
60
+ }
61
+ function renderPreformattedTable(markdown) {
62
+ const rows = parseMarkdownTableToRows(markdown);
63
+ if (!rows || rows.length === 0) {
64
+ return markdown.trim();
65
+ }
66
+ const columnCount = Math.max(...rows.map((row) => row.length));
67
+ const normalizedRows = rows.map((row) => Array.from({ length: columnCount }, (_, index) => row[index] ?? ''));
68
+ const widths = Array.from({ length: columnCount }, (_, index) => Math.max(...normalizedRows.map((row) => getTextWidth(row[index]))));
69
+ const formatRow = (row) => row
70
+ .map((cell, index) => padToWidth(cell, widths[index]))
71
+ .join(' ')
72
+ .trimEnd();
73
+ const header = formatRow(normalizedRows[0]);
74
+ const divider = widths.map((width) => '-'.repeat(width)).join(' ');
75
+ const body = normalizedRows.slice(1).map(formatRow);
76
+ return [header, divider, ...body].join('\n');
77
+ }
5
78
  /**
6
79
  * FormattedMessage component
7
80
  *
8
81
  * Parses a markdown string and renders:
9
82
  * - Standard text with ANSI formatting
10
- * - Markdown tables using ink-table
83
+ * - Markdown tables as preformatted monospace text
11
84
  * - Code blocks (rendered in a box)
12
85
  */
13
- export const FormattedMessage = ({ content }) => {
86
+ export const FormattedMessage = ({ content, deferTables = false }) => {
14
87
  if (!content)
15
88
  return null;
16
89
  const lines = content.split('\n');
@@ -80,7 +153,10 @@ export const FormattedMessage = ({ content }) => {
80
153
  if (block.type === 'table') {
81
154
  if (!block.content.trim())
82
155
  return null;
83
- return _jsx(Table, { data: block.content }, index);
156
+ if (deferTables) {
157
+ return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: DEFERRED_TABLE_PLACEHOLDER }) }, index));
158
+ }
159
+ return (_jsx(Box, { marginY: 1, paddingX: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
84
160
  }
85
161
  if (block.type === 'code') {
86
162
  return (_jsx(Box, { marginY: 1, paddingX: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { dimColor: true, children: block.content }) }, index));