protoagent 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,6 @@ protoagent init --project --force
61
61
  ## Interactive Commands
62
62
 
63
63
  - `/help` — Show available slash commands
64
- - `/clear` — Start a fresh conversation in a new session
65
64
  - `/collapse` — Collapse long system and tool output
66
65
  - `/expand` — Expand collapsed messages
67
66
  - `/quit` or `/exit` — Save and exit
package/dist/App.js CHANGED
@@ -43,8 +43,7 @@ Here's how the terminal UI is laid out (showcasing all options at once for demon
43
43
  ├─────────────────────────────────────────┤
44
44
  │ tokens: 1234↓ 56↑ | ctx: 12% | $0.02 │ static-ish, updates after each turn
45
45
  ├─────────────────────────────────────────┤
46
- │ /clear Clear conversation... │ dynamic, shown when typing /
47
- │ /quit — Exit ProtoAgent │
46
+ │ /quit Exit ProtoAgent │ dynamic, shown when typing /
48
47
  ├─────────────────────────────────────────┤
49
48
  │ ⠹ Running read_file... │ dynamic, shown while loading
50
49
  ├─────────────────────────────────────────┤
@@ -56,47 +55,50 @@ Here's how the terminal UI is laid out (showcasing all options at once for demon
56
55
  │ protoagent --session abc12345 │
57
56
  └─────────────────────────────────────────┘
58
57
  */
59
- import { useState, useEffect, useCallback, useRef } from 'react';
58
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
60
59
  import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
61
60
  import { LeftBar } from './components/LeftBar.js';
62
- import { TextInput, Select, PasswordInput } from '@inkjs/ui';
61
+ import { TextInput, Select } from '@inkjs/ui';
63
62
  import { OpenAI } from 'openai';
64
- import { readConfig, writeConfig, resolveApiKey } from './config.js';
65
- import { loadRuntimeConfig } from './runtime-config.js';
66
- import { getAllProviders, getProvider, getModelPricing, getRequestDefaultParams } from './providers.js';
63
+ import { readConfig, writeConfig, writeInitConfig, resolveApiKey, TargetSelection, ModelSelection, ApiKeyInput } from './config.js';
64
+ import { loadRuntimeConfig, getActiveRuntimeConfigPath } from './runtime-config.js';
65
+ import { getProvider, getModelPricing, getRequestDefaultParams } from './providers.js';
67
66
  import { runAgenticLoop, initializeMessages, } from './agentic-loop.js';
68
- import { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from './tools/index.js';
67
+ import { setDangerouslySkipPermissions, setApprovalHandler, clearApprovalHandler } from './tools/index.js';
69
68
  import { setLogLevel, LogLevel, initLogFile, logger } from './utils/logger.js';
70
69
  import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, generateTitle, } from './sessions.js';
71
70
  import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
72
- import { initializeMcp, closeMcp } from './mcp.js';
71
+ import { initializeMcp, closeMcp, getConnectedMcpServers } from './mcp.js';
73
72
  import { generateSystemPrompt } from './system-prompt.js';
73
+ import { renderFormattedText } from './utils/format-message.js';
74
74
  // ─── Scrollback helpers ───
75
75
  // These functions append text to the permanent scrollback buffer via the
76
76
  // <Static> component. Ink flushes new Static items within its own render
77
77
  // cycle, so there are no timing issues with write()/log-update.
78
78
  function printBanner(addStatic) {
79
- const green = '\x1b[38;2;9;164;105m';
80
- const reset = '\x1b[0m';
81
- addStatic([
82
- `${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
83
- `${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
84
- '',
85
- ].join('\n'));
79
+ addStatic(_jsxs(Text, { children: [_jsx(Text, { color: "#09A469", children: "\u2588\u2580\u2588 \u2588\u2580\u2588 \u2588\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588 \u2584\u2580\u2588 \u2588\u2580\u2580 \u2588\u2580\u2580 \u2588\u2584 \u2588 \u2580\u2588\u2580" }), '\n', _jsx(Text, { color: "#09A469", children: "\u2588\u2580\u2580 \u2588\u2580\u2584 \u2588\u2584\u2588 \u2588 \u2588\u2584\u2588 \u2588\u2580\u2588 \u2588\u2584\u2588 \u2588\u2588\u2584 \u2588 \u2580\u2588 \u2588" }), '\n'] }));
86
80
  }
87
- function printRuntimeHeader(addStatic, config, session, logFilePath, dangerouslyAcceptAll) {
81
+ function printRuntimeHeader(addStatic, config, session, dangerouslySkipPermissions) {
88
82
  const provider = getProvider(config.provider);
89
83
  let line = `Model: ${provider?.name || config.provider} / ${config.model}`;
90
- if (dangerouslyAcceptAll)
84
+ if (dangerouslySkipPermissions)
91
85
  line += ' (auto-approve all)';
92
86
  if (session)
93
- line += ` | Session: ${session.id.slice(0, 8)}`;
94
- let text = `${line}\n`;
87
+ line += ` | Session: ${session.id}`;
88
+ const lines = [_jsx(Text, { dimColor: true, children: line }, "model")];
89
+ const logFilePath = logger.getLogFilePath();
95
90
  if (logFilePath) {
96
- text += `Debug logs: ${logFilePath}\n`;
91
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }, "log"));
97
92
  }
98
- text += '\n';
99
- addStatic(text);
93
+ const configPath = getActiveRuntimeConfigPath();
94
+ if (configPath) {
95
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Config file: ", configPath] }, "config"));
96
+ }
97
+ const mcpServers = getConnectedMcpServers();
98
+ if (mcpServers.length > 0) {
99
+ lines.push(_jsxs(Text, { dimColor: true, children: ["MCPs: ", mcpServers.join(', ')] }, "mcp"));
100
+ }
101
+ addStatic(_jsxs(Text, { children: [lines.map((l, i) => _jsxs(React.Fragment, { children: [l, '\n'] }, i)), '\n'] }));
100
102
  }
101
103
  function normalizeTranscriptText(text) {
102
104
  const normalized = text.replace(/\r\n/g, '\n');
@@ -110,14 +112,72 @@ function normalizeTranscriptText(text) {
110
112
  function printMessageToScrollback(addStatic, role, text) {
111
113
  const normalized = normalizeTranscriptText(text);
112
114
  if (!normalized) {
113
- addStatic('\n');
115
+ addStatic(_jsx(Text, { children: '\n' }));
114
116
  return;
115
117
  }
116
118
  if (role === 'user') {
117
- addStatic(`\x1b[32m>\x1b[0m ${normalized}\n`);
119
+ addStatic(_jsxs(Text, { children: [_jsx(Text, { color: "green", children: '>' }), " ", normalized, '\n'] }));
118
120
  return;
119
121
  }
120
- addStatic(`${normalized}\n\n`);
122
+ // Apply Markdown formatting (bold, italic) to assistant messages
123
+ addStatic(_jsxs(Text, { children: [renderFormattedText(normalized), '\n'] }));
124
+ }
125
+ /**
126
+ * Format a sub-agent tool call into a human-readable activity string.
127
+ * Shows what the sub-agent is actually doing, e.g. "Sub-agent reading file package.json"
128
+ */
129
+ function formatSubAgentActivity(tool, args) {
130
+ if (!args || typeof args !== 'object') {
131
+ return `Sub-agent running ${tool}...`;
132
+ }
133
+ const argEntries = Object.entries(args);
134
+ if (argEntries.length === 0) {
135
+ return `Sub-agent running ${tool}...`;
136
+ }
137
+ // Extract the most meaningful argument based on the tool
138
+ let detail = '';
139
+ const firstValue = argEntries[0]?.[1];
140
+ switch (tool) {
141
+ case 'read_file':
142
+ detail = typeof args.file_path === 'string' ? args.file_path : '';
143
+ break;
144
+ case 'write_file':
145
+ detail = typeof args.file_path === 'string' ? args.file_path : '';
146
+ break;
147
+ case 'edit_file':
148
+ detail = typeof args.file_path === 'string' ? args.file_path : '';
149
+ break;
150
+ case 'list_directory':
151
+ detail = typeof args.directory_path === 'string' ? args.directory_path : '(current)';
152
+ break;
153
+ case 'search_files':
154
+ detail = typeof args.search_term === 'string' ? `"${args.search_term}"` : '';
155
+ break;
156
+ case 'bash':
157
+ detail = typeof args.command === 'string'
158
+ ? args.command.split(/\s+/).slice(0, 3).join(' ') + (args.command.split(/\s+/).length > 3 ? '...' : '')
159
+ : '';
160
+ break;
161
+ case 'todo_write':
162
+ detail = Array.isArray(args.todos) ? `${args.todos.length} task(s)` : '';
163
+ break;
164
+ case 'webfetch':
165
+ detail = typeof args.url === 'string' ? new URL(args.url).hostname : '';
166
+ break;
167
+ case 'sub_agent':
168
+ // Nested sub-agent
169
+ detail = 'nested task...';
170
+ break;
171
+ default:
172
+ // Use the first argument value as fallback
173
+ detail = typeof firstValue === 'string'
174
+ ? firstValue.length > 30 ? firstValue.slice(0, 30) + '...' : firstValue
175
+ : '';
176
+ }
177
+ if (detail) {
178
+ return `Sub-agent ${tool.replace(/_/g, ' ')}: ${detail}`;
179
+ }
180
+ return `Sub-agent running ${tool}...`;
121
181
  }
122
182
  function replayMessagesToScrollback(addStatic, messages) {
123
183
  for (const message of messages) {
@@ -135,11 +195,11 @@ function replayMessagesToScrollback(addStatic, messages) {
135
195
  if (message.role === 'tool') {
136
196
  const toolName = msgAny.name || 'tool';
137
197
  const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
138
- addStatic(`\x1b[2m${toolName}: ${compact}\x1b[0m\n`);
198
+ addStatic(_jsxs(Text, { dimColor: true, children: ['', toolName, ': ', compact, '\n'] }));
139
199
  }
140
200
  }
141
201
  if (messages.length > 0) {
142
- addStatic('\n');
202
+ addStatic(_jsx(Text, { children: '\n' }));
143
203
  }
144
204
  }
145
205
  // Returns only the last N displayable lines of text so the live streaming box
@@ -154,7 +214,6 @@ function clipToRows(text, terminalRows) {
154
214
  }
155
215
  // ─── Available slash commands ───
156
216
  const SLASH_COMMANDS = [
157
- { name: '/clear', description: 'Clear conversation and start fresh' },
158
217
  { name: '/help', description: 'Show all available commands' },
159
218
  { name: '/quit', description: 'Exit ProtoAgent' },
160
219
  { name: '/exit', description: 'Alias for /quit' },
@@ -162,7 +221,6 @@ const SLASH_COMMANDS = [
162
221
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
163
222
  const HELP_TEXT = [
164
223
  'Commands:',
165
- ' /clear - Clear conversation and start fresh',
166
224
  ' /help - Show this help',
167
225
  ' /quit - Exit ProtoAgent',
168
226
  ' /exit - Alias for /quit',
@@ -238,40 +296,33 @@ const UsageDisplay = ({ usage, totalCost }) => {
238
296
  };
239
297
  /** Inline setup wizard — shown when no config exists. */
240
298
  const InlineSetup = ({ onComplete }) => {
241
- const [setupStep, setSetupStep] = useState('provider');
299
+ const [setupStep, setSetupStep] = useState('target');
300
+ const [target, setTarget] = useState('project');
242
301
  const [selectedProviderId, setSelectedProviderId] = useState('');
243
302
  const [selectedModelId, setSelectedModelId] = useState('');
244
- const [apiKeyError, setApiKeyError] = useState('');
245
- const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
246
- label: `${provider.name} - ${model.name}`,
247
- value: `${provider.id}:::${model.id}`,
248
- })));
303
+ const handleModelSelect = (providerId, modelId) => {
304
+ setSelectedProviderId(providerId);
305
+ setSelectedModelId(modelId);
306
+ setSetupStep('api_key');
307
+ };
308
+ const handleConfigComplete = (config) => {
309
+ writeInitConfig(target);
310
+ writeConfig(config, target);
311
+ onComplete(config);
312
+ };
313
+ if (setupStep === 'target') {
314
+ return (_jsx(Box, { marginTop: 1, children: _jsx(TargetSelection, { title: "First-time setup", subtitle: "Create a ProtoAgent runtime config:", onSelect: (value) => {
315
+ setTarget(value);
316
+ setSetupStep('provider');
317
+ } }) }));
318
+ }
249
319
  if (setupStep === 'provider') {
250
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsx(Text, { dimColor: true, children: "Select a provider and model:" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: providerItems.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => {
251
- const [providerId, modelId] = value.split(':::');
252
- setSelectedProviderId(providerId);
253
- setSelectedModelId(modelId);
254
- setSetupStep('api_key');
255
- } }) })] }));
320
+ return (_jsx(Box, { marginTop: 1, children: _jsx(ModelSelection, { setSelectedProviderId: setSelectedProviderId, setSelectedModelId: setSelectedModelId, onSelect: handleModelSelect, title: "First-time setup" }) }));
256
321
  }
257
- const provider = getProvider(selectedProviderId);
258
- const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
259
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsxs(Text, { dimColor: true, children: ["Selected: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key:' : 'Enter your API key:' }), apiKeyError && _jsx(Text, { color: "red", children: apiKeyError }), _jsx(PasswordInput, { placeholder: hasResolvedAuth ? 'Press enter to keep resolved auth' : `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
260
- if (value.trim().length === 0 && !hasResolvedAuth) {
261
- setApiKeyError('API key cannot be empty.');
262
- return;
263
- }
264
- const newConfig = {
265
- provider: selectedProviderId,
266
- model: selectedModelId,
267
- ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
268
- };
269
- writeConfig(newConfig, 'project');
270
- onComplete(newConfig);
271
- } })] }));
322
+ return (_jsx(Box, { marginTop: 1, children: _jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, target: target, title: "First-time setup", showProviderHeaders: false, onComplete: handleConfigComplete }) }));
272
323
  };
273
324
  // ─── Main App ───
274
- export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
325
+ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }) => {
275
326
  const { exit } = useApp();
276
327
  const { stdout } = useStdout();
277
328
  const terminalRows = stdout?.rows ?? 24;
@@ -286,10 +337,10 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
286
337
  // collisions if multiple App instances ever coexist.
287
338
  const staticCounterRef = useRef(0);
288
339
  const [staticItems, setStaticItems] = useState([]);
289
- const addStatic = useCallback((text) => {
340
+ const addStatic = useCallback((node) => {
290
341
  staticCounterRef.current += 1;
291
342
  const id = `s${staticCounterRef.current}`;
292
- setStaticItems((prev) => [...prev, { id, text }]);
343
+ setStaticItems((prev) => [...prev, { id, node }]);
293
344
  }, []);
294
345
  // Core state
295
346
  const [config, setConfig] = useState(null);
@@ -309,7 +360,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
309
360
  const [threadErrors, setThreadErrors] = useState([]);
310
361
  const [initialized, setInitialized] = useState(false);
311
362
  const [needsSetup, setNeedsSetup] = useState(false);
312
- const [logFilePath, setLogFilePath] = useState(null);
313
363
  // Input reset key — incremented on submit to force TextInput remount and clear
314
364
  const [inputResetKey, setInputResetKey] = useState(0);
315
365
  // Approval state
@@ -329,9 +379,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
329
379
  const assistantMessageRef = useRef(null);
330
380
  // Abort controller for cancelling the current completion
331
381
  const abortControllerRef = useRef(null);
382
+ // Buffer for streaming text that accumulates content and flushes complete lines to static
383
+ // This prevents the live streaming area from growing unbounded - complete lines are
384
+ // immediately flushed to <Static>, only the incomplete final line stays in the dynamic frame
385
+ const streamingBufferRef = useRef({
386
+ unflushedContent: '',
387
+ hasFlushedAnyLine: false,
388
+ });
332
389
  const didPrintIntroRef = useRef(false);
333
390
  const printedThreadErrorIdsRef = useRef(new Set());
334
- const printedLogPathRef = useRef(null);
335
391
  // ─── Post-config initialization (reused after inline setup) ───
336
392
  const initializeWithConfig = useCallback(async (loadedConfig) => {
337
393
  setConfig(loadedConfig);
@@ -350,7 +406,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
350
406
  setCompletionMessages(loadedSession.completionMessages);
351
407
  if (!didPrintIntroRef.current) {
352
408
  printBanner(addStatic);
353
- printRuntimeHeader(addStatic, loadedConfig, loadedSession, logFilePath, dangerouslyAcceptAll);
409
+ printRuntimeHeader(addStatic, loadedConfig, loadedSession, dangerouslySkipPermissions);
354
410
  replayMessagesToScrollback(addStatic, loadedSession.completionMessages);
355
411
  didPrintIntroRef.current = true;
356
412
  }
@@ -369,13 +425,13 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
369
425
  setSession(newSession);
370
426
  if (!didPrintIntroRef.current) {
371
427
  printBanner(addStatic);
372
- printRuntimeHeader(addStatic, loadedConfig, newSession, logFilePath, dangerouslyAcceptAll);
428
+ printRuntimeHeader(addStatic, loadedConfig, newSession, dangerouslySkipPermissions);
373
429
  didPrintIntroRef.current = true;
374
430
  }
375
431
  }
376
432
  setNeedsSetup(false);
377
433
  setInitialized(true);
378
- }, [dangerouslyAcceptAll, logFilePath, sessionId, addStatic]);
434
+ }, [dangerouslySkipPermissions, sessionId, addStatic]);
379
435
  // ─── Initialization ───
380
436
  useEffect(() => {
381
437
  if (!loading) {
@@ -389,23 +445,16 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
389
445
  }, [loading]);
390
446
  useEffect(() => {
391
447
  if (error) {
392
- addStatic(`\x1b[31mError: ${error}\x1b[0m\n\n`);
448
+ addStatic(_jsxs(Text, { color: "red", children: ["Error: ", error] }));
393
449
  }
394
450
  }, [error, addStatic]);
395
- useEffect(() => {
396
- if (!didPrintIntroRef.current || !logFilePath || printedLogPathRef.current === logFilePath) {
397
- return;
398
- }
399
- printedLogPathRef.current = logFilePath;
400
- addStatic(`Debug logs: ${logFilePath}\n\n`);
401
- }, [logFilePath, addStatic]);
402
451
  useEffect(() => {
403
452
  for (const threadError of threadErrors) {
404
453
  if (threadError.transient || printedThreadErrorIdsRef.current.has(threadError.id)) {
405
454
  continue;
406
455
  }
407
456
  printedThreadErrorIdsRef.current.add(threadError.id);
408
- addStatic(`\x1b[31mError: ${threadError.message}\x1b[0m\n\n`);
457
+ addStatic(_jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }));
409
458
  }
410
459
  }, [threadErrors, addStatic]);
411
460
  useEffect(() => {
@@ -415,15 +464,14 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
415
464
  const level = LogLevel[logLevel.toUpperCase()];
416
465
  if (level !== undefined) {
417
466
  setLogLevel(level);
418
- const logPath = initLogFile();
419
- setLogFilePath(logPath);
467
+ initLogFile();
420
468
  logger.info(`ProtoAgent started with log level: ${logLevel}`);
421
- logger.info(`Log file: ${logPath}`);
469
+ logger.info(`Log file: ${logger.getLogFilePath()}`);
422
470
  }
423
471
  }
424
472
  // Set global approval mode
425
- if (dangerouslyAcceptAll) {
426
- setDangerouslyAcceptAll(true);
473
+ if (dangerouslySkipPermissions) {
474
+ setDangerouslySkipPermissions(true);
427
475
  }
428
476
  // Register interactive approval handler
429
477
  setApprovalHandler(async (req) => {
@@ -477,23 +525,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
477
525
  setError(`Failed to save session before exit: ${err.message}`);
478
526
  }
479
527
  return true;
480
- case '/clear':
481
- // Re-initialize messages with just the system prompt
482
- initializeMessages().then((msgs) => {
483
- setCompletionMessages(msgs);
484
- setHelpMessage(null);
485
- setLastUsage(null);
486
- setTotalCost(0);
487
- setThreadErrors([]);
488
- if (session) {
489
- const newSession = createSession(config.model, config.provider);
490
- clearTodos(session.id);
491
- clearTodos(newSession.id);
492
- newSession.completionMessages = msgs;
493
- setSession(newSession);
494
- }
495
- });
496
- return true;
497
528
  case '/expand':
498
529
  case '/collapse':
499
530
  // expand/collapse removed — transcript lives in scrollback
@@ -527,8 +558,9 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
527
558
  setError(null);
528
559
  setHelpMessage(null);
529
560
  setThreadErrors([]);
530
- // Reset turn tracking
561
+ // Reset turn tracking and streaming buffer
531
562
  assistantMessageRef.current = null;
563
+ streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
532
564
  // Print the user message directly to scrollback so it is selectable/copyable.
533
565
  // We still push it into completionMessages for session saving.
534
566
  const userMessage = { role: 'user', content: trimmed };
@@ -544,50 +576,92 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
544
576
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
545
577
  switch (event.type) {
546
578
  case 'text_delta': {
547
- // Accumulate tokens into streamingText React state — shown live in
548
- // the dynamic Ink frame. The frame height stays constant (spinner +
549
- // streaming box + input) so setState here does NOT trigger
550
- // clearTerminal. At 'done' the full text is flushed to <Static>.
579
+ const deltaText = event.content || '';
580
+ // First text delta of this turn: initialize ref, show streaming indicator.
551
581
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
552
- // First text delta of this turn: initialise ref, show streaming indicator.
553
- const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
582
+ // Trim leading whitespace from first delta - LLMs often output leading \n or spaces
583
+ const trimmedDelta = deltaText.replace(/^\s+/, '');
584
+ const assistantMsg = { role: 'assistant', content: trimmedDelta, tool_calls: [] };
554
585
  const idx = completionMessages.length + 1;
555
586
  assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
556
587
  setIsStreaming(true);
557
- setStreamingText(event.content || '');
558
588
  setCompletionMessages((prev) => [...prev, assistantMsg]);
589
+ // Initialize the streaming buffer and process the first chunk
590
+ // through the same split logic as subsequent deltas for consistency
591
+ const buffer = { unflushedContent: trimmedDelta, hasFlushedAnyLine: false };
592
+ streamingBufferRef.current = buffer;
593
+ // Process the first chunk: split on newlines and flush complete lines
594
+ const lines = buffer.unflushedContent.split('\n');
595
+ if (lines.length > 1) {
596
+ const completeLines = lines.slice(0, -1);
597
+ const textToFlush = completeLines.join('\n');
598
+ if (textToFlush) {
599
+ addStatic(renderFormattedText(textToFlush));
600
+ buffer.hasFlushedAnyLine = true;
601
+ }
602
+ buffer.unflushedContent = lines[lines.length - 1];
603
+ }
604
+ setStreamingText(buffer.unflushedContent);
559
605
  }
560
606
  else {
561
- // Subsequent deltas — append to ref AND to React state for live display.
562
- assistantMessageRef.current.message.content += event.content || '';
563
- setStreamingText((prev) => prev + (event.content || ''));
607
+ // Subsequent deltas — append to ref and buffer, then flush complete lines
608
+ assistantMessageRef.current.message.content += deltaText;
609
+ // Accumulate in buffer and flush complete lines to static
610
+ const buffer = streamingBufferRef.current;
611
+ buffer.unflushedContent += deltaText;
612
+ // Split on newlines to find complete lines
613
+ const lines = buffer.unflushedContent.split('\n');
614
+ // If we have more than 1 element, there were newlines
615
+ if (lines.length > 1) {
616
+ // All lines except the last one are complete (ended with \n)
617
+ const completeLines = lines.slice(0, -1);
618
+ // Build the text to flush - each complete line gets a newline added back
619
+ const textToFlush = completeLines.join('\n');
620
+ if (textToFlush) {
621
+ addStatic(renderFormattedText(textToFlush));
622
+ buffer.hasFlushedAnyLine = true;
623
+ }
624
+ // Keep only the last (incomplete) line in the buffer
625
+ buffer.unflushedContent = lines[lines.length - 1];
626
+ }
627
+ // Show the incomplete line (if any) in the dynamic frame
628
+ setStreamingText(buffer.unflushedContent);
564
629
  }
565
630
  break;
566
631
  }
567
632
  case 'sub_agent_iteration':
568
633
  if (event.subAgentTool) {
569
- const { tool, status } = event.subAgentTool;
634
+ const { tool, status, args } = event.subAgentTool;
570
635
  if (status === 'running') {
571
- setActiveTool(`sub_agent → ${tool}`);
636
+ setActiveTool(formatSubAgentActivity(tool, args));
572
637
  }
573
638
  else {
574
639
  setActiveTool(null);
575
640
  }
576
641
  }
642
+ // Handle sub-agent usage update
643
+ if (event.subAgentUsage) {
644
+ setTotalCost((prev) => prev + event.subAgentUsage.cost);
645
+ }
577
646
  break;
578
647
  case 'tool_call':
579
648
  if (event.toolCall) {
580
649
  const toolCall = event.toolCall;
581
650
  setActiveTool(toolCall.name);
582
651
  // If the model streamed some text before invoking this tool,
583
- // flush it to <Static> now. Without this, streamingText is
584
- // never cleared the 'done' handler only flushes streaming_text
585
- // when the turn ends with plain text, not with tool calls.
652
+ // flush any remaining unflushed content to <Static> now.
653
+ // The streaming buffer contains text that hasn't been flushed yet
654
+ // (the incomplete final line). We need to flush it before the tool call.
586
655
  if (assistantMessageRef.current?.kind === 'streaming_text') {
587
- const precedingText = assistantMessageRef.current.message.content || '';
588
- if (precedingText) {
589
- addStatic(`${normalizeTranscriptText(precedingText)}\n\n`);
656
+ const buffer = streamingBufferRef.current;
657
+ // Flush any remaining unflushed content
658
+ if (buffer.unflushedContent) {
659
+ addStatic(renderFormattedText(buffer.unflushedContent));
590
660
  }
661
+ // Add spacing after the streamed text and before the tool call
662
+ addStatic(renderFormattedText('\n'));
663
+ // Reset streaming state and buffer
664
+ streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
591
665
  setIsStreaming(false);
592
666
  setStreamingText('');
593
667
  assistantMessageRef.current = null;
@@ -646,7 +720,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
646
720
  .replace(/\s+/g, ' ')
647
721
  .trim()
648
722
  .slice(0, 180);
649
- addStatic(`\x1b[2m${toolCall.name}: ${compactResult}\x1b[0m\n`);
723
+ addStatic(_jsxs(Text, { dimColor: true, children: ['', toolCall.name, ': ', compactResult, '\n'] }));
650
724
  // Flush the assistant message + tool result into completionMessages
651
725
  // for session saving.
652
726
  setCompletionMessages((prev) => {
@@ -713,14 +787,32 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
713
787
  case 'done':
714
788
  if (assistantMessageRef.current?.kind === 'streaming_text') {
715
789
  const finalRef = assistantMessageRef.current;
716
- // Flush the complete streamed text to <Static> (permanent scrollback),
717
- // then clear the live streaming state from the dynamic Ink frame.
718
- const normalized = normalizeTranscriptText(finalRef.message.content || '');
719
- if (normalized) {
720
- addStatic(`${normalized}\n\n`);
790
+ const buffer = streamingBufferRef.current;
791
+ // Flush any remaining unflushed content from the buffer
792
+ // This is the final incomplete line that was being displayed live
793
+ if (buffer.unflushedContent) {
794
+ // If we've already flushed some lines, just append the remainder
795
+ // Otherwise, normalize and flush the full content
796
+ if (buffer.hasFlushedAnyLine) {
797
+ addStatic(renderFormattedText(buffer.unflushedContent));
798
+ }
799
+ else {
800
+ // Nothing was flushed yet, normalize the full content
801
+ const normalized = normalizeTranscriptText(finalRef.message.content || '');
802
+ if (normalized) {
803
+ addStatic(renderFormattedText(normalized));
804
+ }
805
+ }
806
+ }
807
+ // Add final spacing after the streamed text
808
+ // Always add one newline - the user message adds another for blank line separation
809
+ if (buffer.unflushedContent) {
810
+ addStatic(renderFormattedText('\n'));
721
811
  }
812
+ // Clear streaming state and buffer
722
813
  setIsStreaming(false);
723
814
  setStreamingText('');
815
+ streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
724
816
  setCompletionMessages((prev) => {
725
817
  const updated = [...prev];
726
818
  updated[finalRef.index] = { ...finalRef.message };
@@ -766,11 +858,11 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
766
858
  }
767
859
  });
768
860
  // ─── Render ───
769
- 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) => {
861
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => (_jsx(Text, { children: item.node }, 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) => {
770
862
  initializeWithConfig(newConfig).catch((err) => {
771
863
  setError(`Initialization failed: ${err.message}`);
772
864
  });
773
- } })), 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) => {
865
+ } })), isStreaming && (_jsxs(Text, { wrap: "wrap", children: [renderFormattedText(clipToRows(streamingText, terminalRows)), _jsx(Text, { dimColor: true, children: "\u258D" })] })), threadErrors.filter((threadError) => threadError.transient).map((threadError) => (_jsx(LeftBar, { color: "gray", marginBottom: 1, children: _jsx(Text, { color: "gray", children: threadError.message }) }, `thread-error-${threadError.id}`))), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
774
866
  pendingApproval.resolve(response);
775
867
  setPendingApproval(null);
776
868
  } })), initialized && !pendingApproval && loading && !isStreaming && (_jsx(Box, { children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), 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] })] }))] }));