protoagent 0.1.5 → 0.1.7

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,10 +6,10 @@ 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, useMemo } from 'react';
10
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
9
+ import { useState, useEffect, useCallback, useRef } from 'react';
10
+ import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
11
+ import { LeftBar } from './components/LeftBar.js';
11
12
  import { TextInput, Select, PasswordInput } from '@inkjs/ui';
12
- import BigText from 'ink-big-text';
13
13
  import { OpenAI } from 'openai';
14
14
  import { readConfig, writeConfig, resolveApiKey } from './config.js';
15
15
  import { loadRuntimeConfig } from './runtime-config.js';
@@ -21,117 +21,103 @@ import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, gener
21
21
  import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
22
22
  import { initializeMcp, closeMcp } from './mcp.js';
23
23
  import { generateSystemPrompt } from './system-prompt.js';
24
- import { CollapsibleBox } from './components/CollapsibleBox.js';
25
- import { ConsolidatedToolMessage } from './components/ConsolidatedToolMessage.js';
26
- import { FormattedMessage } from './components/FormattedMessage.js';
27
- function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0, deferTables = false) {
28
- const rendered = [];
29
- const skippedIndices = new Set();
30
- messagesToRender.forEach((msg, localIndex) => {
31
- if (skippedIndices.has(localIndex)) {
32
- return;
33
- }
34
- const index = startIndex + localIndex;
35
- const msgAny = msg;
36
- const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
37
- const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
38
- const normalizedContent = normalizeMessageSpacing(displayContent || '');
39
- const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
40
- const previousMessage = index > 0 ? allMessages[index - 1] : null;
41
- const followsToolMessage = previousMessage?.role === 'tool';
42
- const currentSpeaker = getVisualSpeaker(msg);
43
- const previousSpeaker = getVisualSpeaker(previousMessage);
44
- const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
45
- const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
46
- const speakerChanged = previousSpeaker !== currentSpeaker;
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) {
54
- rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
55
- }
56
- if (msg.role === 'user') {
57
- rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
58
- return;
59
- }
60
- if (msg.role === 'system') {
61
- rendered.push(_jsx(CollapsibleBox, { title: "System Prompt", content: displayContent || '', titleColor: "green", dimColor: false, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
62
- return;
63
- }
64
- if (isToolCall) {
65
- if (normalizedContent.length > 0) {
66
- rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, `${index}-text`));
67
- }
68
- const toolCalls = msgAny.tool_calls.map((tc) => ({
69
- id: tc.id,
70
- name: tc.function?.name || 'tool',
71
- }));
72
- const toolResults = new Map();
73
- let nextLocalIndex = localIndex + 1;
74
- for (const toolCall of toolCalls) {
75
- if (nextLocalIndex < messagesToRender.length) {
76
- const nextMsg = messagesToRender[nextLocalIndex];
77
- if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
78
- toolResults.set(toolCall.id, {
79
- content: normalizeMessageSpacing(nextMsg.content || ''),
80
- name: nextMsg.name || toolCall.name,
81
- });
82
- skippedIndices.add(nextLocalIndex);
83
- nextLocalIndex++;
84
- }
85
- }
86
- }
87
- rendered.push(_jsx(ConsolidatedToolMessage, { toolCalls: toolCalls, toolResults: toolResults, expanded: expandedMessages.has(index) }, index));
88
- return;
89
- }
90
- if (msg.role === 'tool') {
91
- rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: normalizedContent, dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
92
- return;
93
- }
94
- rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, index));
95
- });
96
- return rendered;
24
+ // ─── Scrollback helpers ───
25
+ // These functions append text to the permanent scrollback buffer via the
26
+ // <Static> component. Ink flushes new Static items within its own render
27
+ // cycle, so there are no timing issues with write()/log-update.
28
+ let _staticCounter = 0;
29
+ function makeStaticId() {
30
+ return `s${++_staticCounter}`;
97
31
  }
98
- function normalizeMessageSpacing(message) {
99
- const normalized = message.replace(/\r\n/g, '\n');
32
+ function printBanner(addStatic) {
33
+ const green = '\x1b[38;2;9;164;105m';
34
+ const reset = '\x1b[0m';
35
+ addStatic([
36
+ `${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
37
+ `${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
38
+ '',
39
+ ].join('\n'));
40
+ }
41
+ function printRuntimeHeader(addStatic, config, session, logFilePath, dangerouslyAcceptAll) {
42
+ const provider = getProvider(config.provider);
43
+ let line = `Model: ${provider?.name || config.provider} / ${config.model}`;
44
+ if (dangerouslyAcceptAll)
45
+ line += ' (auto-approve all)';
46
+ if (session)
47
+ line += ` | Session: ${session.id.slice(0, 8)}`;
48
+ let text = `${line}\n`;
49
+ if (logFilePath) {
50
+ text += `Debug logs: ${logFilePath}\n`;
51
+ }
52
+ text += '\n';
53
+ addStatic(text);
54
+ }
55
+ function normalizeTranscriptText(text) {
56
+ const normalized = text.replace(/\r\n/g, '\n');
100
57
  const lines = normalized.split('\n');
101
- while (lines.length > 0 && lines[0].trim() === '') {
58
+ while (lines.length > 0 && lines[0].trim() === '')
102
59
  lines.shift();
103
- }
104
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
60
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
105
61
  lines.pop();
106
- }
107
62
  return lines.join('\n');
108
63
  }
109
- function getVisualSpeaker(message) {
110
- if (!message)
111
- return null;
112
- if (message.role === 'tool')
113
- return 'assistant';
114
- if (message.role === 'user' || message.role === 'assistant' || message.role === 'system') {
115
- return message.role;
64
+ function printMessageToScrollback(addStatic, role, text) {
65
+ const normalized = normalizeTranscriptText(text);
66
+ if (!normalized) {
67
+ addStatic('\n');
68
+ return;
69
+ }
70
+ if (role === 'user') {
71
+ addStatic(`\x1b[32m>\x1b[0m ${normalized}\n`);
72
+ return;
116
73
  }
117
- return null;
74
+ addStatic(`${normalized}\n\n`);
75
+ }
76
+ function replayMessagesToScrollback(addStatic, messages) {
77
+ for (const message of messages) {
78
+ const msgAny = message;
79
+ if (message.role === 'system')
80
+ continue;
81
+ if (message.role === 'user' && typeof message.content === 'string') {
82
+ printMessageToScrollback(addStatic, 'user', message.content);
83
+ continue;
84
+ }
85
+ if (message.role === 'assistant' && typeof message.content === 'string' && message.content.trim().length > 0) {
86
+ printMessageToScrollback(addStatic, 'assistant', message.content);
87
+ continue;
88
+ }
89
+ if (message.role === 'tool') {
90
+ const toolName = msgAny.name || 'tool';
91
+ const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
92
+ addStatic(`\x1b[2m▶ ${toolName}: ${compact}\x1b[0m\n`);
93
+ }
94
+ }
95
+ if (messages.length > 0) {
96
+ addStatic('\n');
97
+ }
98
+ }
99
+ // Returns only the last N displayable lines of text so the live streaming box
100
+ // never grows taller than the terminal, preventing Ink's clearTerminal wipe.
101
+ const STREAMING_RESERVED_ROWS = 3; // usage bar + spinner + input line
102
+ function clipToRows(text, terminalRows) {
103
+ const maxLines = Math.max(1, terminalRows - STREAMING_RESERVED_ROWS);
104
+ const lines = text.split('\n');
105
+ if (lines.length <= maxLines)
106
+ return text;
107
+ return lines.slice(lines.length - maxLines).join('\n');
118
108
  }
119
109
  // ─── Available slash commands ───
120
110
  const SLASH_COMMANDS = [
121
111
  { name: '/clear', description: 'Clear conversation and start fresh' },
122
- { name: '/collapse', description: 'Collapse all long messages' },
123
- { name: '/expand', description: 'Expand all collapsed messages' },
124
112
  { name: '/help', description: 'Show all available commands' },
125
113
  { name: '/quit', description: 'Exit ProtoAgent' },
126
114
  ];
127
115
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
128
116
  const HELP_TEXT = [
129
117
  'Commands:',
130
- ' /clear - Clear conversation and start fresh',
131
- ' /collapse - Collapse all long messages',
132
- ' /expand - Expand all collapsed messages',
133
- ' /help - Show this help',
134
- ' /quit - Exit ProtoAgent',
118
+ ' /clear - Clear conversation and start fresh',
119
+ ' /help - Show this help',
120
+ ' /quit - Exit ProtoAgent',
135
121
  ].join('\n');
136
122
  function buildClient(config) {
137
123
  const provider = getProvider(config.provider);
@@ -194,13 +180,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
194
180
  { label: sessionApprovalLabel, value: 'approve_session' },
195
181
  { label: 'Reject', value: 'reject' },
196
182
  ];
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) }) })] }));
183
+ return (_jsxs(LeftBar, { color: "green", marginTop: 1, marginBottom: 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) }) })] }));
198
184
  };
199
185
  /** Cost/usage display in the status bar. */
200
186
  const UsageDisplay = ({ usage, totalCost }) => {
201
187
  if (!usage && totalCost === 0)
202
188
  return null;
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)] }))] }));
189
+ 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)] }))] }));
204
190
  };
205
191
  /** Inline setup wizard — shown when no config exists. */
206
192
  const InlineSetup = ({ onComplete }) => {
@@ -240,10 +226,27 @@ const InlineSetup = ({ onComplete }) => {
240
226
  export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
241
227
  const { exit } = useApp();
242
228
  const { stdout } = useStdout();
229
+ const terminalRows = stdout?.rows ?? 24;
230
+ // ─── Static scrollback state ───
231
+ // Each item appended here is rendered once by <Static> and permanently
232
+ // flushed to the terminal scrollback by Ink, within its own render cycle.
233
+ // This eliminates all write()/log-update timing issues.
234
+ const [staticItems, setStaticItems] = useState([]);
235
+ const addStatic = useCallback((text) => {
236
+ setStaticItems((prev) => [...prev, { id: makeStaticId(), text }]);
237
+ }, []);
243
238
  // Core state
244
239
  const [config, setConfig] = useState(null);
245
240
  const [completionMessages, setCompletionMessages] = useState([]);
246
241
  const [inputText, setInputText] = useState('');
242
+ // isStreaming: true while the assistant is producing tokens.
243
+ // streamingText: the live in-progress token buffer shown in the dynamic Ink
244
+ // frame while the response streams. Cleared to '' at done and flushed to
245
+ // <Static> as a permanent scrollback item. Keeping it in React state (not a
246
+ // ref) is safe because the Ink frame height does NOT change as tokens arrive —
247
+ // the streaming box is always 1+ lines tall while loading=true.
248
+ const [isStreaming, setIsStreaming] = useState(false);
249
+ const [streamingText, setStreamingText] = useState('');
247
250
  const [loading, setLoading] = useState(false);
248
251
  const [error, setError] = useState(null);
249
252
  const [helpMessage, setHelpMessage] = useState(null);
@@ -253,18 +256,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
253
256
  const [logFilePath, setLogFilePath] = useState(null);
254
257
  // Input reset key — incremented on submit to force TextInput remount and clear
255
258
  const [inputResetKey, setInputResetKey] = useState(0);
256
- const [inputWidthKey, setInputWidthKey] = useState(stdout?.columns ?? 80);
257
- // Collapsible state — only applies to live (current turn) messages
258
- const [expandedMessages, setExpandedMessages] = useState(new Set());
259
- const expandLatestMessage = useCallback((index) => {
260
- setExpandedMessages((prev) => {
261
- if (prev.has(index))
262
- return prev;
263
- const next = new Set(prev);
264
- next.add(index);
265
- return next;
266
- });
267
- }, []);
268
259
  // Approval state
269
260
  const [pendingApproval, setPendingApproval] = useState(null);
270
261
  // Usage state
@@ -279,12 +270,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
279
270
  const [quittingSession, setQuittingSession] = useState(null);
280
271
  // OpenAI client ref (stable across renders)
281
272
  const clientRef = useRef(null);
282
- // Track current assistant message being built in the event handler
283
273
  const assistantMessageRef = useRef(null);
284
274
  // Abort controller for cancelling the current completion
285
275
  const abortControllerRef = useRef(null);
286
- // Debounce timer for text_delta renders (~50ms batching)
287
- const textFlushTimerRef = useRef(null);
276
+ const didPrintIntroRef = useRef(false);
277
+ const printedThreadErrorIdsRef = useRef(new Set());
278
+ const printedLogPathRef = useRef(null);
288
279
  // ─── Post-config initialization (reused after inline setup) ───
289
280
  const initializeWithConfig = useCallback(async (loadedConfig) => {
290
281
  setConfig(loadedConfig);
@@ -301,6 +292,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
301
292
  setTodosForSession(loadedSession.id, loadedSession.todos);
302
293
  setSession(loadedSession);
303
294
  setCompletionMessages(loadedSession.completionMessages);
295
+ if (!didPrintIntroRef.current) {
296
+ printBanner(addStatic);
297
+ printRuntimeHeader(addStatic, loadedConfig, loadedSession, logFilePath, dangerouslyAcceptAll);
298
+ replayMessagesToScrollback(addStatic, loadedSession.completionMessages);
299
+ didPrintIntroRef.current = true;
300
+ }
304
301
  }
305
302
  else {
306
303
  setError(`Session "${sessionId}" not found. Starting a new session.`);
@@ -314,10 +311,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
314
311
  clearTodos(newSession.id);
315
312
  newSession.completionMessages = initialCompletionMessages;
316
313
  setSession(newSession);
314
+ if (!didPrintIntroRef.current) {
315
+ printBanner(addStatic);
316
+ printRuntimeHeader(addStatic, loadedConfig, newSession, logFilePath, dangerouslyAcceptAll);
317
+ didPrintIntroRef.current = true;
318
+ }
317
319
  }
318
320
  setNeedsSetup(false);
319
321
  setInitialized(true);
320
- }, [sessionId]);
322
+ }, [dangerouslyAcceptAll, logFilePath, sessionId, addStatic]);
321
323
  // ─── Initialization ───
322
324
  useEffect(() => {
323
325
  if (!loading) {
@@ -330,17 +332,26 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
330
332
  return () => clearInterval(interval);
331
333
  }, [loading]);
332
334
  useEffect(() => {
333
- if (!stdout)
335
+ if (error) {
336
+ addStatic(`\x1b[31mError: ${error}\x1b[0m\n\n`);
337
+ }
338
+ }, [error, addStatic]);
339
+ useEffect(() => {
340
+ if (!didPrintIntroRef.current || !logFilePath || printedLogPathRef.current === logFilePath) {
334
341
  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]);
342
+ }
343
+ printedLogPathRef.current = logFilePath;
344
+ addStatic(`Debug logs: ${logFilePath}\n\n`);
345
+ }, [logFilePath, addStatic]);
346
+ useEffect(() => {
347
+ for (const threadError of threadErrors) {
348
+ if (threadError.transient || printedThreadErrorIdsRef.current.has(threadError.id)) {
349
+ continue;
350
+ }
351
+ printedThreadErrorIdsRef.current.add(threadError.id);
352
+ addStatic(`\x1b[31mError: ${threadError.message}\x1b[0m\n\n`);
353
+ }
354
+ }, [threadErrors, addStatic]);
344
355
  useEffect(() => {
345
356
  const init = async () => {
346
357
  // Set log level and initialize log file
@@ -418,7 +429,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
418
429
  setLastUsage(null);
419
430
  setTotalCost(0);
420
431
  setThreadErrors([]);
421
- setExpandedMessages(new Set());
422
432
  if (session) {
423
433
  const newSession = createSession(config.model, config.provider);
424
434
  clearTodos(session.id);
@@ -429,13 +439,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
429
439
  });
430
440
  return true;
431
441
  case '/expand':
432
- // Expand all collapsed messages
433
- const allIndices = new Set(completionMessages.map((_, i) => i));
434
- setExpandedMessages(allIndices);
435
- return true;
436
442
  case '/collapse':
437
- // Collapse all messages
438
- setExpandedMessages(new Set());
443
+ // expand/collapse removed — transcript lives in scrollback
439
444
  return true;
440
445
  case '/help':
441
446
  setHelpMessage(HELP_TEXT);
@@ -461,13 +466,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
461
466
  setInputText('');
462
467
  setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
463
468
  setLoading(true);
469
+ setIsStreaming(false);
470
+ setStreamingText('');
464
471
  setError(null);
465
472
  setHelpMessage(null);
466
473
  setThreadErrors([]);
467
- // Add user message to completion messages IMMEDIATELY for real-time UI display
474
+ // Reset turn tracking
475
+ assistantMessageRef.current = null;
476
+ // Print the user message directly to scrollback so it is selectable/copyable.
477
+ // We still push it into completionMessages for session saving.
468
478
  const userMessage = { role: 'user', content: trimmed };
479
+ printMessageToScrollback(addStatic, 'user', trimmed);
469
480
  setCompletionMessages((prev) => [...prev, userMessage]);
470
- // Reset assistant message tracker
481
+ // Reset assistant message tracker (streamed indices were reset above)
471
482
  assistantMessageRef.current = null;
472
483
  try {
473
484
  const pricing = getModelPricing(config.provider, config.model);
@@ -476,30 +487,35 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
476
487
  abortControllerRef.current = new AbortController();
477
488
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
478
489
  switch (event.type) {
479
- case 'text_delta':
480
- // Update the current assistant message in completionMessages
490
+ case 'text_delta': {
491
+ // Accumulate tokens into streamingText React state — shown live in
492
+ // the dynamic Ink frame. The frame height stays constant (spinner +
493
+ // streaming box + input) so setState here does NOT trigger
494
+ // clearTerminal. At 'done' the full text is flushed to <Static>.
481
495
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
482
- // First text delta create the assistant message immediately
496
+ // First text delta of this turn: initialise ref, show streaming indicator.
483
497
  const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
484
- setCompletionMessages((prev) => {
485
- assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
486
- return [...prev, assistantMsg];
487
- });
498
+ const idx = completionMessages.length + 1;
499
+ assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
500
+ setIsStreaming(true);
501
+ setStreamingText(event.content || '');
502
+ setCompletionMessages((prev) => [...prev, assistantMsg]);
488
503
  }
489
504
  else {
490
- // Subsequent deltas — accumulate in ref, debounce the render (~50ms)
505
+ // Subsequent deltas — append to ref AND to React state for live display.
491
506
  assistantMessageRef.current.message.content += event.content || '';
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);
507
+ setStreamingText((prev) => prev + (event.content || ''));
508
+ }
509
+ break;
510
+ }
511
+ case 'sub_agent_iteration':
512
+ if (event.subAgentTool) {
513
+ const { tool, status } = event.subAgentTool;
514
+ if (status === 'running') {
515
+ setActiveTool(`sub_agent → ${tool}`);
516
+ }
517
+ else {
518
+ setActiveTool(null);
503
519
  }
504
520
  }
505
521
  break;
@@ -553,16 +569,20 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
553
569
  if (event.toolCall) {
554
570
  const toolCall = event.toolCall;
555
571
  setActiveTool(null);
556
- if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
557
- const currentAssistantIndex = assistantMessageRef.current?.index;
558
- if (typeof currentAssistantIndex === 'number') {
559
- expandLatestMessage(currentAssistantIndex);
560
- }
561
- }
562
- // Flush the assistant message update + tool result in a SINGLE state update
572
+ // Write the tool summary immediately at this point loading is
573
+ // still true but the frame height is stable (spinner + input box).
574
+ // The next state change (setActiveTool(null)) doesn't affect
575
+ // frame height so write() restores the correct frame.
576
+ const compactResult = (toolCall.result || '')
577
+ .replace(/\s+/g, ' ')
578
+ .trim()
579
+ .slice(0, 180);
580
+ addStatic(`\x1b[2m▶ ${toolCall.name}: ${compactResult}\x1b[0m\n`);
581
+ // Flush the assistant message + tool result into completionMessages
582
+ // for session saving.
563
583
  setCompletionMessages((prev) => {
564
584
  const updated = [...prev];
565
- // Sync assistant message (may have new tool_calls since last render)
585
+ // Sync assistant message
566
586
  if (assistantMessageRef.current) {
567
587
  updated[assistantMessageRef.current.index] = {
568
588
  ...assistantMessageRef.current.message,
@@ -617,13 +637,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
617
637
  }
618
638
  break;
619
639
  case 'iteration_done':
620
- assistantMessageRef.current = null;
640
+ if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
641
+ assistantMessageRef.current = null;
642
+ }
621
643
  break;
622
644
  case 'done':
623
- // Clear any pending text delta timer
624
- if (textFlushTimerRef.current) {
625
- clearTimeout(textFlushTimerRef.current);
626
- textFlushTimerRef.current = null;
645
+ if (assistantMessageRef.current?.kind === 'streaming_text') {
646
+ const finalRef = assistantMessageRef.current;
647
+ // Flush the complete streamed text to <Static> (permanent scrollback),
648
+ // then clear the live streaming state from the dynamic Ink frame.
649
+ const normalized = normalizeTranscriptText(finalRef.message.content || '');
650
+ if (normalized) {
651
+ addStatic(`${normalized}\n\n`);
652
+ }
653
+ setIsStreaming(false);
654
+ setStreamingText('');
655
+ setCompletionMessages((prev) => {
656
+ const updated = [...prev];
657
+ updated[finalRef.index] = { ...finalRef.message };
658
+ return updated;
659
+ });
660
+ assistantMessageRef.current = null;
627
661
  }
628
662
  setActiveTool(null);
629
663
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
@@ -651,7 +685,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
651
685
  finally {
652
686
  setLoading(false);
653
687
  }
654
- }, [loading, config, completionMessages, session, handleSlashCommand, expandLatestMessage]);
688
+ }, [loading, config, completionMessages, session, handleSlashCommand, addStatic]);
655
689
  // ─── Keyboard shortcuts ───
656
690
  useInput((input, key) => {
657
691
  if (key.ctrl && input === 'c') {
@@ -663,25 +697,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
663
697
  }
664
698
  });
665
699
  // ─── Render ───
666
- const providerInfo = config ? getProvider(config.provider) : null;
667
- const liveStartIndex = loading
668
- ? (typeof assistantMessageRef.current?.index === 'number'
669
- ? assistantMessageRef.current.index
670
- : Math.max(completionMessages.length - 1, 0))
671
- : completionMessages.length;
672
- const archivedMessages = completionMessages.slice(0, liveStartIndex);
673
- const liveMessages = completionMessages.slice(liveStartIndex);
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) => {
700
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => (_jsx(Text, { children: item.text }, item.id)) }), helpMessage && (_jsx(LeftBar, { color: "green", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: helpMessage }) })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
677
701
  initializeWithConfig(newConfig).catch((err) => {
678
702
  setError(`Initialization failed: ${err.message}`);
679
703
  });
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 && ((() => {
681
- const lastMsg = completionMessages[completionMessages.length - 1];
682
- return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
683
- })()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
684
- pendingApproval.resolve(response);
685
- setPendingApproval(null);
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] })] }))] }));
704
+ } })), isStreaming && (_jsxs(Text, { wrap: "wrap", children: [clipToRows(streamingText, terminalRows), _jsx(Text, { dimColor: true, children: "\u258D" })] })), threadErrors.filter((threadError) => threadError.transient).map((threadError) => (_jsx(LeftBar, { color: "red", marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }) }, `thread-error-${threadError.id}`))), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
705
+ pendingApproval.resolve(response);
706
+ setPendingApproval(null);
707
+ } })), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && !isStreaming && (_jsx(Box, { children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && (_jsx(Box, { 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) })] }) })), 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
708
  };
@@ -126,6 +126,21 @@ function extractFirstCompleteJsonValue(value) {
126
126
  }
127
127
  return null;
128
128
  }
129
+ /**
130
+ * Repair invalid JSON escape sequences in a string value.
131
+ *
132
+ * JSON only allows: \" \\ \/ \b \f \n \r \t \uXXXX
133
+ * Models sometimes emit \| \! \- etc. (e.g. grep regex args) which make
134
+ * JSON.parse throw, and Anthropic strict-validates tool_call arguments on
135
+ * every subsequent request, bricking the session permanently.
136
+ *
137
+ * We double the backslash for any \X where X is not a valid JSON escape char.
138
+ */
139
+ function repairInvalidEscapes(value) {
140
+ // Match a backslash followed by any character that is NOT a valid JSON escape
141
+ // Valid escapes: " \ / b f n r t u
142
+ return value.replace(/\\([^"\\\/bfnrtu])/g, '\\\\$1');
143
+ }
129
144
  function normalizeJsonArguments(argumentsText) {
130
145
  const trimmed = argumentsText.trim();
131
146
  if (!trimmed)
@@ -157,6 +172,25 @@ function normalizeJsonArguments(argumentsText) {
157
172
  // Give up and return the original text below.
158
173
  }
159
174
  }
175
+ // Heuristic: repair invalid escape sequences (e.g. \| from grep regex args)
176
+ const repaired = repairInvalidEscapes(trimmed);
177
+ if (repaired !== trimmed) {
178
+ try {
179
+ JSON.parse(repaired);
180
+ return repaired;
181
+ }
182
+ catch {
183
+ // Try repair + first-value extraction together
184
+ const repairedFirst = extractFirstCompleteJsonValue(repaired);
185
+ if (repairedFirst) {
186
+ try {
187
+ JSON.parse(repairedFirst);
188
+ return repairedFirst;
189
+ }
190
+ catch { /* give up */ }
191
+ }
192
+ }
193
+ }
160
194
  return argumentsText;
161
195
  }
162
196
  function sanitizeToolCall(toolCall, validToolNames) {
@@ -231,6 +265,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
231
265
  }
232
266
  let iterationCount = 0;
233
267
  let repairRetryCount = 0;
268
+ let contextRetryCount = 0;
234
269
  const validToolNames = getValidToolNames();
235
270
  while (iterationCount < maxIterations) {
236
271
  // Check if abort was requested
@@ -389,10 +424,24 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
389
424
  })),
390
425
  });
391
426
  updatedMessages.push(assistantMessage);
427
+ // Track which tool_call_ids still need a tool result message.
428
+ // This set is used to inject stub responses on abort, preventing
429
+ // orphaned tool_call_ids from permanently bricking the session.
430
+ const pendingToolCallIds = new Set(assistantMessage.tool_calls.map((tc) => tc.id));
431
+ const injectStubsForPendingToolCalls = () => {
432
+ for (const id of pendingToolCallIds) {
433
+ updatedMessages.push({
434
+ role: 'tool',
435
+ tool_call_id: id,
436
+ content: 'Aborted by user.',
437
+ });
438
+ }
439
+ };
392
440
  for (const toolCall of assistantMessage.tool_calls) {
393
441
  // Check abort between tool calls
394
442
  if (abortSignal?.aborted) {
395
443
  logger.debug('Agentic loop aborted between tool calls');
444
+ injectStubsForPendingToolCalls();
396
445
  emitAbortAndFinish(onEvent);
397
446
  return updatedMessages;
398
447
  }
@@ -408,16 +457,11 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
408
457
  if (name === 'sub_agent') {
409
458
  const subProgress = (evt) => {
410
459
  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
- },
460
+ type: 'sub_agent_iteration',
461
+ subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration },
418
462
  });
419
463
  };
420
- result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
464
+ result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal);
421
465
  }
422
466
  else {
423
467
  result = await handleToolCall(name, args, { sessionId, abortSignal });
@@ -433,6 +477,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
433
477
  tool_call_id: toolCall.id,
434
478
  content: result,
435
479
  });
480
+ pendingToolCallIds.delete(toolCall.id);
436
481
  onEvent({
437
482
  type: 'tool_result',
438
483
  toolCall: { id: toolCall.id, name, args: argsStr, status: 'done', result },
@@ -445,6 +490,14 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
445
490
  tool_call_id: toolCall.id,
446
491
  content: `Error: ${errMsg}`,
447
492
  });
493
+ pendingToolCallIds.delete(toolCall.id);
494
+ // If the tool was aborted, inject stubs for remaining pending calls and stop
495
+ if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
496
+ logger.debug('Agentic loop aborted during tool execution');
497
+ injectStubsForPendingToolCalls();
498
+ emitAbortAndFinish(onEvent);
499
+ return updatedMessages;
500
+ }
448
501
  onEvent({
449
502
  type: 'tool_result',
450
503
  toolCall: { id: toolCall.id, name, args: argsStr, status: 'error', result: errMsg },
@@ -520,6 +573,48 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
520
573
  continue;
521
574
  }
522
575
  }
576
+ // Handle context-window-exceeded (prompt too long) — attempt forced compaction
577
+ // This fires when our token estimate was too low (e.g. base64 images from MCP tools)
578
+ // and the request actually hit the hard provider limit.
579
+ const isContextTooLong = apiError?.status === 400 &&
580
+ typeof errMsg === 'string' &&
581
+ /prompt.{0,30}too long|context.{0,30}length|maximum.{0,30}token|tokens?.{0,10}exceed/i.test(errMsg);
582
+ if (isContextTooLong && contextRetryCount < 2) {
583
+ contextRetryCount++;
584
+ logger.warn(`Prompt too long (attempt ${contextRetryCount}); forcing compaction`, { errMsg });
585
+ onEvent({
586
+ type: 'error',
587
+ error: 'Prompt too long. Compacting conversation and retrying...',
588
+ transient: true,
589
+ });
590
+ if (pricing) {
591
+ // Use the normal LLM-based compaction path
592
+ try {
593
+ const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow,
594
+ // Pass the context window itself as currentTokens to force compaction
595
+ pricing.contextWindow, requestDefaults, sessionId);
596
+ updatedMessages.length = 0;
597
+ updatedMessages.push(...compacted);
598
+ }
599
+ catch (compactErr) {
600
+ logger.error(`Forced compaction failed: ${compactErr}`);
601
+ // Fall through to truncation fallback below
602
+ }
603
+ }
604
+ // Fallback: truncate any tool result messages whose content looks like
605
+ // base64 or is extremely large (e.g. MCP screenshot data)
606
+ const MAX_TOOL_RESULT_CHARS = 20_000;
607
+ for (let i = 0; i < updatedMessages.length; i++) {
608
+ const m = updatedMessages[i];
609
+ if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > MAX_TOOL_RESULT_CHARS) {
610
+ updatedMessages[i] = {
611
+ ...m,
612
+ content: m.content.slice(0, MAX_TOOL_RESULT_CHARS) + '\n... (truncated — content was too large)',
613
+ };
614
+ }
615
+ }
616
+ continue;
617
+ }
523
618
  // Retry on 429 (rate limit) with backoff
524
619
  if (apiError?.status === 429) {
525
620
  const retryAfter = parseInt(apiError?.headers?.['retry-after'] || '5', 10);
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
2
+ import { Text } from 'ink';
3
+ import { LeftBar } from './LeftBar.js';
3
4
  export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, maxPreviewLines = 3, maxPreviewChars = 500, expanded = false, marginBottom = 0, }) => {
4
5
  const lines = content.split('\n');
5
6
  const isTooManyLines = lines.length > maxPreviewLines;
@@ -7,7 +8,7 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
7
8
  const isLong = isTooManyLines || isTooManyChars;
8
9
  // If content is short, always show it
9
10
  if (!isLong) {
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
+ return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Text, { dimColor: dimColor, children: content })] }));
11
12
  }
12
13
  // For long content, show preview or full content
13
14
  let preview;
@@ -22,5 +23,5 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
22
23
  : linesTruncated;
23
24
  }
24
25
  const hasMore = !expanded;
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
+ return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] }));
26
27
  };
@@ -1,19 +1,20 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { FormattedMessage } from './FormattedMessage.js';
4
+ import { LeftBar } from './LeftBar.js';
4
5
  export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = false, }) => {
5
6
  const toolNames = toolCalls.map((toolCall) => toolCall.name);
6
7
  const title = `Called: ${toolNames.join(', ')}`;
7
8
  const containsTodoTool = toolCalls.some((toolCall) => toolCall.name === 'todo_read' || toolCall.name === 'todo_write');
8
- const titleColor = containsTodoTool ? 'green' : 'white';
9
+ const titleColor = containsTodoTool ? 'green' : 'cyan';
9
10
  const isExpanded = expanded || containsTodoTool;
10
11
  if (isExpanded) {
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) => {
12
- const result = toolResults.get(toolCall.id);
13
- if (!result)
14
- return null;
15
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
16
- }) })] }));
12
+ return (_jsxs(LeftBar, { color: titleColor, children: [_jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }), toolCalls.map((toolCall, idx) => {
13
+ const result = toolResults.get(toolCall.id);
14
+ if (!result)
15
+ return null;
16
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
17
+ })] }));
17
18
  }
18
19
  const compactLines = toolCalls.flatMap((toolCall) => {
19
20
  const result = toolResults.get(toolCall.id);
@@ -29,5 +30,5 @@ export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = fal
29
30
  const preview = compactPreview.length > previewLimit
30
31
  ? `${compactPreview.slice(0, previewLimit).trimEnd()}... (use /expand)`
31
32
  : compactPreview;
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 }) })] }));
33
+ return (_jsxs(LeftBar, { color: "white", children: [_jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }), _jsx(Text, { dimColor: true, children: preview })] }));
33
34
  };
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { formatMessage } from '../utils/format-message.js';
4
+ import { LeftBar } from './LeftBar.js';
4
5
  export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
5
6
  const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
6
7
  ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
@@ -156,10 +157,10 @@ export const FormattedMessage = ({ content, deferTables = false }) => {
156
157
  if (deferTables) {
157
158
  return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: DEFERRED_TABLE_PLACEHOLDER }) }, index));
158
159
  }
159
- return (_jsx(Box, { marginY: 1, paddingX: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
160
+ return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
160
161
  }
161
162
  if (block.type === 'code') {
162
- return (_jsx(Box, { marginY: 1, paddingX: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { dimColor: true, children: block.content }) }, index));
163
+ return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: block.content }) }, index));
163
164
  }
164
165
  // Text Block
165
166
  if (!block.content.trim())
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * LeftBar — renders a bold green vertical bar (│) on the left side of
4
+ * content, like a GitHub "note" callout. The bar stretches to match the
5
+ * full height of the content by measuring the content box after each render
6
+ * and repeating the │ character once per row.
7
+ *
8
+ * Uses Ink's measureElement (available in stock Ink) rather than a Box
9
+ * border, so it adds zero extra border lines and avoids ghosting on resize.
10
+ */
11
+ import { useRef, useState, useLayoutEffect } from 'react';
12
+ import { Box, Text, measureElement } from 'ink';
13
+ export const LeftBar = ({ color = 'green', children, marginTop = 0, marginBottom = 0, }) => {
14
+ const contentRef = useRef(null);
15
+ const [height, setHeight] = useState(1);
16
+ useLayoutEffect(() => {
17
+ if (contentRef.current) {
18
+ try {
19
+ const { height: h } = measureElement(contentRef.current);
20
+ if (h > 0)
21
+ setHeight(h);
22
+ }
23
+ catch {
24
+ // measureElement can throw before layout is complete; keep previous height
25
+ }
26
+ }
27
+ });
28
+ const bar = Array.from({ length: height }, () => '│').join('\n');
29
+ return (_jsxs(Box, { flexDirection: "row", marginTop: marginTop, marginBottom: marginBottom, children: [_jsx(Box, { flexDirection: "column", marginRight: 1, children: _jsx(Text, { color: color, bold: true, children: bar }) }), _jsx(Box, { ref: contentRef, flexDirection: "column", flexGrow: 1, children: children })] }));
30
+ };
package/dist/sub-agent.js CHANGED
@@ -39,7 +39,7 @@ export const subAgentTool = {
39
39
  * Run a sub-agent with its own isolated conversation.
40
40
  * Returns the sub-agent's final text response.
41
41
  */
42
- export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress) {
42
+ export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress, abortSignal) {
43
43
  const op = logger.startOperation('sub-agent');
44
44
  const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
45
45
  const systemPrompt = await generateSystemPrompt();
@@ -56,13 +56,17 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
56
56
  ];
57
57
  try {
58
58
  for (let i = 0; i < maxIterations; i++) {
59
+ // Check abort at the top of each iteration
60
+ if (abortSignal?.aborted) {
61
+ return '(sub-agent aborted)';
62
+ }
59
63
  const response = await client.chat.completions.create({
60
64
  ...requestDefaults,
61
65
  model,
62
66
  messages,
63
67
  tools: getAllTools(),
64
68
  tool_choice: 'auto',
65
- });
69
+ }, { signal: abortSignal });
66
70
  const message = response.choices[0]?.message;
67
71
  if (!message)
68
72
  break;
@@ -70,12 +74,16 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
70
74
  if (message.tool_calls && message.tool_calls.length > 0) {
71
75
  messages.push(message);
72
76
  for (const toolCall of message.tool_calls) {
77
+ // Check abort between tool calls
78
+ if (abortSignal?.aborted) {
79
+ return '(sub-agent aborted)';
80
+ }
73
81
  const { name, arguments: argsStr } = toolCall.function;
74
82
  logger.debug(`Sub-agent tool call: ${name}`);
75
83
  onProgress?.({ tool: name, status: 'running', iteration: i });
76
84
  try {
77
85
  const args = JSON.parse(argsStr);
78
- const result = await handleToolCall(name, args, { sessionId: subAgentSessionId });
86
+ const result = await handleToolCall(name, args, { sessionId: subAgentSessionId, abortSignal });
79
87
  messages.push({
80
88
  role: 'tool',
81
89
  tool_call_id: toolCall.id,
@@ -85,13 +85,13 @@ GUIDELINES
85
85
 
86
86
  OUTPUT FORMAT:
87
87
  - You are running in a terminal. Be concise. Optimise for scannability.
88
- - Use **bold** for important terms, *italic* for references.
89
- - For structured data, do NOT use Markdown table syntax. Use plain text, preformatted monospace tables with aligned columns.
90
- - Keep text tables compact: prefer narrower columns, minimal horizontal padding, and multi-line cells over very wide tables.
91
- - When a table would be too wide, wrap cell content onto multiple lines and abbreviate non-essential text so it fits terminal widths cleanly.
92
- - Use flat bullet lists with emojis to communicate information densely (e.g. ✅ done, ❌ failed, 📁 file, 🔍 searching).
88
+ - Do NOT use Markdown formatting. No **bold**, no *italic*, no # headers, no --- dividers.
89
+ - Do NOT use Markdown code fences (backticks) unless the content is actual code or a command.
90
+ - For structured data, use plain text with aligned columns (spaces, not pipes/dashes).
91
+ - Keep tables compact: narrower columns, minimal padding. Wrap cell content rather than making very wide tables.
92
+ - Use flat plain-text lists with a simple dash or symbol prefix (e.g. - item, or ✅ done, ❌ failed).
93
93
  - NEVER use nested indentation. Keep all lists flat — one level only.
94
- - Markdown links [text](url) are NOT supported — just write URLs inline.
94
+ - Do NOT use Markdown links [text](url) — just write URLs inline.
95
95
 
96
96
  SUBAGENT STRATEGY:
97
97
  Delegate work to specialized subagents aggressively. They excel at focused, parallel tasks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -28,7 +28,7 @@
28
28
  "commander": "^14.0.1",
29
29
  "he": "^1.2.0",
30
30
  "html-to-text": "^9.0.5",
31
- "ink": "^6.7.0",
31
+ "ink": "^6.8.0",
32
32
  "ink-big-text": "^2.0.0",
33
33
  "jsonc-parser": "^3.3.1",
34
34
  "openai": "^5.23.1",