protoagent 0.1.11 → 0.1.13

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
@@ -55,7 +55,7 @@ Here's how the terminal UI is laid out (showcasing all options at once for demon
55
55
  │ protoagent --session abc12345 │
56
56
  └─────────────────────────────────────────┘
57
57
  */
58
- import { useState, useEffect, useCallback, useRef } from 'react';
58
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
59
59
  import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
60
60
  import { LeftBar } from './components/LeftBar.js';
61
61
  import { TextInput, Select } from '@inkjs/ui';
@@ -70,18 +70,13 @@ import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, gener
70
70
  import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
71
71
  import { initializeMcp, closeMcp, getConnectedMcpServers } from './mcp.js';
72
72
  import { generateSystemPrompt } from './system-prompt.js';
73
+ import { renderFormattedText } from './utils/format-message.js';
73
74
  // ─── Scrollback helpers ───
74
75
  // These functions append text to the permanent scrollback buffer via the
75
76
  // <Static> component. Ink flushes new Static items within its own render
76
77
  // cycle, so there are no timing issues with write()/log-update.
77
78
  function printBanner(addStatic) {
78
- const green = '\x1b[38;2;9;164;105m';
79
- const reset = '\x1b[0m';
80
- addStatic([
81
- `${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
82
- `${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
83
- '',
84
- ].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'] }));
85
80
  }
86
81
  function printRuntimeHeader(addStatic, config, session, dangerouslySkipPermissions) {
87
82
  const provider = getProvider(config.provider);
@@ -90,21 +85,20 @@ function printRuntimeHeader(addStatic, config, session, dangerouslySkipPermissio
90
85
  line += ' (auto-approve all)';
91
86
  if (session)
92
87
  line += ` | Session: ${session.id}`;
93
- let text = `\x1b[2m${line}\x1b[0m\n`;
88
+ const lines = [_jsx(Text, { dimColor: true, children: line }, "model")];
94
89
  const logFilePath = logger.getLogFilePath();
95
90
  if (logFilePath) {
96
- text += `\x1b[2mDebug logs: ${logFilePath}\x1b[0m\n`;
91
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }, "log"));
97
92
  }
98
93
  const configPath = getActiveRuntimeConfigPath();
99
94
  if (configPath) {
100
- text += `\x1b[2mConfig file: ${configPath}\x1b[0m\n`;
95
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Config file: ", configPath] }, "config"));
101
96
  }
102
97
  const mcpServers = getConnectedMcpServers();
103
98
  if (mcpServers.length > 0) {
104
- text += `\x1b[2mMCPs: ${mcpServers.join(', ')}\x1b[0m\n`;
99
+ lines.push(_jsxs(Text, { dimColor: true, children: ["MCPs: ", mcpServers.join(', ')] }, "mcp"));
105
100
  }
106
- text += '\n';
107
- addStatic(text);
101
+ addStatic(_jsxs(Text, { children: [lines.map((l, i) => _jsxs(React.Fragment, { children: [l, '\n'] }, i)), '\n'] }));
108
102
  }
109
103
  function normalizeTranscriptText(text) {
110
104
  const normalized = text.replace(/\r\n/g, '\n');
@@ -118,14 +112,15 @@ function normalizeTranscriptText(text) {
118
112
  function printMessageToScrollback(addStatic, role, text) {
119
113
  const normalized = normalizeTranscriptText(text);
120
114
  if (!normalized) {
121
- addStatic('\n');
115
+ addStatic(_jsx(Text, { children: '\n' }));
122
116
  return;
123
117
  }
124
118
  if (role === 'user') {
125
- addStatic(`\x1b[32m>\x1b[0m ${normalized}\n`);
119
+ addStatic(_jsxs(Text, { children: [_jsx(Text, { color: "green", children: '>' }), " ", normalized, '\n'] }));
126
120
  return;
127
121
  }
128
- addStatic(`${normalized}\n\n`);
122
+ // Apply Markdown formatting (bold, italic) to assistant messages
123
+ addStatic(_jsxs(Text, { children: [renderFormattedText(normalized), '\n'] }));
129
124
  }
130
125
  /**
131
126
  * Format a sub-agent tool call into a human-readable activity string.
@@ -200,11 +195,11 @@ function replayMessagesToScrollback(addStatic, messages) {
200
195
  if (message.role === 'tool') {
201
196
  const toolName = msgAny.name || 'tool';
202
197
  const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
203
- addStatic(`\x1b[2m${toolName}: ${compact}\x1b[0m\n`);
198
+ addStatic(_jsxs(Text, { dimColor: true, children: ['', toolName, ': ', compact, '\n'] }));
204
199
  }
205
200
  }
206
201
  if (messages.length > 0) {
207
- addStatic('\n');
202
+ addStatic(_jsx(Text, { children: '\n' }));
208
203
  }
209
204
  }
210
205
  // Returns only the last N displayable lines of text so the live streaming box
@@ -342,10 +337,10 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
342
337
  // collisions if multiple App instances ever coexist.
343
338
  const staticCounterRef = useRef(0);
344
339
  const [staticItems, setStaticItems] = useState([]);
345
- const addStatic = useCallback((text) => {
340
+ const addStatic = useCallback((node) => {
346
341
  staticCounterRef.current += 1;
347
342
  const id = `s${staticCounterRef.current}`;
348
- setStaticItems((prev) => [...prev, { id, text }]);
343
+ setStaticItems((prev) => [...prev, { id, node }]);
349
344
  }, []);
350
345
  // Core state
351
346
  const [config, setConfig] = useState(null);
@@ -384,6 +379,13 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
384
379
  const assistantMessageRef = useRef(null);
385
380
  // Abort controller for cancelling the current completion
386
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
+ });
387
389
  const didPrintIntroRef = useRef(false);
388
390
  const printedThreadErrorIdsRef = useRef(new Set());
389
391
  // ─── Post-config initialization (reused after inline setup) ───
@@ -443,7 +445,7 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
443
445
  }, [loading]);
444
446
  useEffect(() => {
445
447
  if (error) {
446
- addStatic(`\x1b[31mError: ${error}\x1b[0m\n\n`);
448
+ addStatic(_jsxs(Text, { color: "red", children: ["Error: ", error] }));
447
449
  }
448
450
  }, [error, addStatic]);
449
451
  useEffect(() => {
@@ -452,7 +454,7 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
452
454
  continue;
453
455
  }
454
456
  printedThreadErrorIdsRef.current.add(threadError.id);
455
- addStatic(`\x1b[31mError: ${threadError.message}\x1b[0m\n\n`);
457
+ addStatic(_jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }));
456
458
  }
457
459
  }, [threadErrors, addStatic]);
458
460
  useEffect(() => {
@@ -556,8 +558,9 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
556
558
  setError(null);
557
559
  setHelpMessage(null);
558
560
  setThreadErrors([]);
559
- // Reset turn tracking
561
+ // Reset turn tracking and streaming buffer
560
562
  assistantMessageRef.current = null;
563
+ streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
561
564
  // Print the user message directly to scrollback so it is selectable/copyable.
562
565
  // We still push it into completionMessages for session saving.
563
566
  const userMessage = { role: 'user', content: trimmed };
@@ -573,23 +576,56 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
573
576
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
574
577
  switch (event.type) {
575
578
  case 'text_delta': {
576
- // Accumulate tokens into streamingText React state — shown live in
577
- // the dynamic Ink frame. The frame height stays constant (spinner +
578
- // streaming box + input) so setState here does NOT trigger
579
- // 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.
580
581
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
581
- // First text delta of this turn: initialise ref, show streaming indicator.
582
- 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: [] };
583
585
  const idx = completionMessages.length + 1;
584
586
  assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
585
587
  setIsStreaming(true);
586
- setStreamingText(event.content || '');
587
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);
588
605
  }
589
606
  else {
590
- // Subsequent deltas — append to ref AND to React state for live display.
591
- assistantMessageRef.current.message.content += event.content || '';
592
- 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);
593
629
  }
594
630
  break;
595
631
  }
@@ -613,14 +649,19 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
613
649
  const toolCall = event.toolCall;
614
650
  setActiveTool(toolCall.name);
615
651
  // If the model streamed some text before invoking this tool,
616
- // flush it to <Static> now. Without this, streamingText is
617
- // never cleared the 'done' handler only flushes streaming_text
618
- // 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.
619
655
  if (assistantMessageRef.current?.kind === 'streaming_text') {
620
- const precedingText = assistantMessageRef.current.message.content || '';
621
- if (precedingText) {
622
- 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));
623
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 };
624
665
  setIsStreaming(false);
625
666
  setStreamingText('');
626
667
  assistantMessageRef.current = null;
@@ -679,7 +720,7 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
679
720
  .replace(/\s+/g, ' ')
680
721
  .trim()
681
722
  .slice(0, 180);
682
- addStatic(`\x1b[2m${toolCall.name}: ${compactResult}\x1b[0m\n`);
723
+ addStatic(_jsxs(Text, { dimColor: true, children: ['', toolCall.name, ': ', compactResult, '\n'] }));
683
724
  // Flush the assistant message + tool result into completionMessages
684
725
  // for session saving.
685
726
  setCompletionMessages((prev) => {
@@ -746,14 +787,32 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
746
787
  case 'done':
747
788
  if (assistantMessageRef.current?.kind === 'streaming_text') {
748
789
  const finalRef = assistantMessageRef.current;
749
- // Flush the complete streamed text to <Static> (permanent scrollback),
750
- // then clear the live streaming state from the dynamic Ink frame.
751
- const normalized = normalizeTranscriptText(finalRef.message.content || '');
752
- if (normalized) {
753
- 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'));
754
811
  }
812
+ // Clear streaming state and buffer
755
813
  setIsStreaming(false);
756
814
  setStreamingText('');
815
+ streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
757
816
  setCompletionMessages((prev) => {
758
817
  const updated = [...prev];
759
818
  updated[finalRef.index] = { ...finalRef.message };
@@ -799,11 +858,11 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
799
858
  }
800
859
  });
801
860
  // ─── Render ───
802
- 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) => {
803
862
  initializeWithConfig(newConfig).catch((err) => {
804
863
  setError(`Initialization failed: ${err.message}`);
805
864
  });
806
- } })), 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: "gray", marginBottom: 1, children: _jsx(Text, { color: "gray", children: 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) => {
807
866
  pendingApproval.resolve(response);
808
867
  setPendingApproval(null);
809
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] })] }))] }));
@@ -281,8 +281,10 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
281
281
  let contextRetryCount = 0;
282
282
  let retriggerCount = 0;
283
283
  let truncateRetryCount = 0;
284
+ let continueRetryCount = 0;
284
285
  const MAX_RETRIGGERS = 3;
285
286
  const MAX_TRUNCATE_RETRIES = 5;
287
+ const MAX_CONTINUE_RETRIES = 1;
286
288
  const validToolNames = getValidToolNames();
287
289
  while (iterationCount < maxIterations) {
288
290
  // Check if abort was requested
@@ -714,6 +716,21 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
714
716
  continue;
715
717
  }
716
718
  }
719
+ // After truncation retries exhausted, try adding a "continue" message
720
+ if (continueRetryCount < MAX_CONTINUE_RETRIES) {
721
+ continueRetryCount++;
722
+ updatedMessages.push({ role: 'user', content: 'continue' });
723
+ logger.warn('400 error: adding "continue" message to retry', {
724
+ continueRetryCount,
725
+ messageCount: updatedMessages.length,
726
+ });
727
+ onEvent({
728
+ type: 'error',
729
+ error: 'Request failed. Retrying with "continue"...',
730
+ transient: true,
731
+ });
732
+ continue;
733
+ }
717
734
  }
718
735
  // Handle context-window-exceeded (prompt too long) — attempt forced compaction
719
736
  // This fires when our token estimate was too low (e.g. base64 images from MCP tools)
@@ -778,7 +795,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
778
795
  if (apiError?.status === 400) {
779
796
  onEvent({
780
797
  type: 'error',
781
- error: 'The conversation history appears to be corrupted and could not be automatically repaired. Try /clear to start fresh.',
798
+ error: `Request failed: ${errMsg}\n\nThe conversation history could not be automatically repaired. Try /clear to start fresh.`,
782
799
  transient: false,
783
800
  });
784
801
  onEvent({ type: 'done' });
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { formatMessage } from '../utils/format-message.js';
3
+ import { renderFormattedText } from '../utils/format-message.js';
4
4
  import { LeftBar } from './LeftBar.js';
5
5
  export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
6
6
  const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
@@ -165,6 +165,6 @@ export const FormattedMessage = ({ content, deferTables = false }) => {
165
165
  // Text Block
166
166
  if (!block.content.trim())
167
167
  return null;
168
- return (_jsx(Box, { marginBottom: 0, children: _jsx(Text, { children: formatMessage(block.content) }) }, index));
168
+ return (_jsx(Box, { marginBottom: 0, children: _jsx(Text, { children: renderFormattedText(block.content) }) }, index));
169
169
  }) }));
170
170
  };
@@ -130,7 +130,8 @@ GUIDELINES
130
130
 
131
131
  OUTPUT FORMAT:
132
132
  - You are running in a terminal. Be concise. Optimise for scannability.
133
- - Do NOT use Markdown formatting. No **bold**, no *italic*, no # headers, no --- dividers.
133
+ - Use **bold** and *italic* formatting tastefully to highlight key points and structure your responses.
134
+ - Do NOT use # headers, --- dividers, or other structural Markdown.
134
135
  - Do NOT use Markdown code fences (backticks) unless the content is actual code or a command.
135
136
  - For structured data, use plain text with aligned columns (spaces, not pipes/dashes).
136
137
  - Keep tables compact: narrower columns, minimal padding. Wrap cell content rather than making very wide tables.
@@ -1,26 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ function parseSegments(text) {
4
+ const segments = [];
5
+ // Strip markdown headers
6
+ const cleaned = text.replace(/^#+\s+/gm, '');
7
+ // Pattern to match ***bold italic***, **bold**, *italic*
8
+ const pattern = /(\*\*\*[^*]+?\*\*\*|\*\*[^*]+?\*\*|\*[^\s*][^*]*?\*)/g;
9
+ let lastIndex = 0;
10
+ let match;
11
+ while ((match = pattern.exec(cleaned)) !== null) {
12
+ // Add plain text before match
13
+ if (match.index > lastIndex) {
14
+ segments.push({ text: cleaned.slice(lastIndex, match.index) });
15
+ }
16
+ const fullMatch = match[0];
17
+ let content;
18
+ let bold = false;
19
+ let italic = false;
20
+ if (fullMatch.startsWith('***')) {
21
+ content = fullMatch.slice(3, -3);
22
+ bold = true;
23
+ italic = true;
24
+ }
25
+ else if (fullMatch.startsWith('**')) {
26
+ content = fullMatch.slice(2, -2);
27
+ bold = true;
28
+ }
29
+ else {
30
+ content = fullMatch.slice(1, -1);
31
+ italic = true;
32
+ }
33
+ segments.push({ text: content, bold, italic });
34
+ lastIndex = pattern.lastIndex;
35
+ }
36
+ // Add remaining plain text
37
+ if (lastIndex < cleaned.length) {
38
+ segments.push({ text: cleaned.slice(lastIndex) });
39
+ }
40
+ return segments.length > 0 ? segments : [{ text: cleaned }];
41
+ }
1
42
  /**
2
- * Parse Markdown-style formatting and convert to ANSI escape codes.
3
- *
4
- * Supports:
5
- * - **bold** → bold text
6
- * - *italic* → italic text
7
- * - ***bold italic*** → bold + italic text
8
- *
9
- * Returns a string with ANSI escape codes that Ink will render with styling.
43
+ * Render formatted text as Ink Text elements.
44
+ * Returns an array of <Text> components that can be nested inside a parent <Text>.
10
45
  */
11
- export function formatMessage(text) {
12
- // ANSI escape codes for styling
13
- const BOLD = '\x1b[1m';
14
- const ITALIC = '\x1b[3m';
15
- const RESET = '\x1b[0m';
16
- let result = text;
17
- // Strip markdown hashtags (headers)
18
- result = result.replace(/^#+\s+/gm, '');
19
- // Replace ***bold italic*** first (to avoid matching ** or * inside)
20
- result = result.replace(/\*\*\*(.+?)\*\*\*/g, `${BOLD}${ITALIC}$1${RESET}`);
21
- // Replace **bold**
22
- result = result.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`);
23
- // Replace *italic*
24
- result = result.replace(/\*(.+?)\*/g, `${ITALIC}$1${RESET}`);
25
- return result;
46
+ export function renderFormattedText(text) {
47
+ const segments = parseSegments(text);
48
+ if (segments.length === 1 && !segments[0].bold && !segments[0].italic) {
49
+ return segments[0].text;
50
+ }
51
+ return segments.map((seg, i) => (_jsx(Text, { bold: seg.bold, italic: seg.italic, children: seg.text }, i)));
26
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",