protoagent 0.1.6 → 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}`;
31
+ }
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);
97
54
  }
98
- function normalizeMessageSpacing(message) {
99
- const normalized = message.replace(/\r\n/g, '\n');
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;
73
+ }
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');
116
97
  }
117
- return null;
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,33 +487,27 @@ 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);
503
- }
507
+ setStreamingText((prev) => prev + (event.content || ''));
504
508
  }
505
509
  break;
510
+ }
506
511
  case 'sub_agent_iteration':
507
512
  if (event.subAgentTool) {
508
513
  const { tool, status } = event.subAgentTool;
@@ -564,16 +569,20 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
564
569
  if (event.toolCall) {
565
570
  const toolCall = event.toolCall;
566
571
  setActiveTool(null);
567
- if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
568
- const currentAssistantIndex = assistantMessageRef.current?.index;
569
- if (typeof currentAssistantIndex === 'number') {
570
- expandLatestMessage(currentAssistantIndex);
571
- }
572
- }
573
- // 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.
574
583
  setCompletionMessages((prev) => {
575
584
  const updated = [...prev];
576
- // Sync assistant message (may have new tool_calls since last render)
585
+ // Sync assistant message
577
586
  if (assistantMessageRef.current) {
578
587
  updated[assistantMessageRef.current.index] = {
579
588
  ...assistantMessageRef.current.message,
@@ -628,13 +637,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
628
637
  }
629
638
  break;
630
639
  case 'iteration_done':
631
- assistantMessageRef.current = null;
640
+ if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
641
+ assistantMessageRef.current = null;
642
+ }
632
643
  break;
633
644
  case 'done':
634
- // Clear any pending text delta timer
635
- if (textFlushTimerRef.current) {
636
- clearTimeout(textFlushTimerRef.current);
637
- 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;
638
661
  }
639
662
  setActiveTool(null);
640
663
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
@@ -662,7 +685,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
662
685
  finally {
663
686
  setLoading(false);
664
687
  }
665
- }, [loading, config, completionMessages, session, handleSlashCommand, expandLatestMessage]);
688
+ }, [loading, config, completionMessages, session, handleSlashCommand, addStatic]);
666
689
  // ─── Keyboard shortcuts ───
667
690
  useInput((input, key) => {
668
691
  if (key.ctrl && input === 'c') {
@@ -674,25 +697,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
674
697
  }
675
698
  });
676
699
  // ─── Render ───
677
- const providerInfo = config ? getProvider(config.provider) : null;
678
- const liveStartIndex = loading
679
- ? (typeof assistantMessageRef.current?.index === 'number'
680
- ? assistantMessageRef.current.index
681
- : Math.max(completionMessages.length - 1, 0))
682
- : completionMessages.length;
683
- const archivedMessages = completionMessages.slice(0, liveStartIndex);
684
- const liveMessages = completionMessages.slice(liveStartIndex);
685
- const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
686
- const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
687
- 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) => {
688
701
  initializeWithConfig(newConfig).catch((err) => {
689
702
  setError(`Initialization failed: ${err.message}`);
690
703
  });
691
- } })), _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 && ((() => {
692
- const lastMsg = completionMessages[completionMessages.length - 1];
693
- return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
694
- })()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
695
- pendingApproval.resolve(response);
696
- setPendingApproval(null);
697
- } }))] }), _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] })] }))] }));
698
708
  };
@@ -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
+ };
@@ -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.6",
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",