protoagent 0.1.6 → 0.1.8

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
@@ -1,15 +1,65 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Main UI component — the heart of ProtoAgent's terminal interface.
4
- *
5
- * Renders the chat loop, tool call feedback, approval prompts,
6
- * and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
7
- * this file is purely presentation + state wiring.
8
- */
9
- import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
10
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
+ Main UI component — the heart of ProtoAgent's terminal interface.
4
+
5
+ Renders the chat loop, tool call feedback, approval prompts,
6
+ and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
7
+ this file is purely presentation + state wiring.
8
+
9
+ Here's how the terminal UI is laid out (showcasing all options at once for demonstration, but in practice many elements are conditional on state):
10
+ ┌─────────────────────────────────────────┐
11
+ │ ProtoAgent (BigText logo) │ static, rendered once (printBanner)
12
+ │ Model: Anthropic / claude-3-5 | Sess.. │ static header (printRuntimeHeader)
13
+ │ Debug logs: /path/to/log │ static, if --log-level set
14
+ ├─────────────────────────────────────────┤
15
+ │ │
16
+ │ [System Prompt ▸ collapsed] │ archived (memoized)
17
+ │ │
18
+ │ > user message │ archived (memoized)
19
+ │ │
20
+ │ assistant reply text │ archived (memoized)
21
+ │ │
22
+ │ [tool_name ▸ collapsed] │ archived (memoized)
23
+ │ │
24
+ │ > user message │ archived (memoized)
25
+ │ │
26
+ ├ ─ ─ ─ ─ ─ ─ ─ live boundary ─ ─ ─ ─ ─ ─ ┤
27
+ │ │
28
+ │ assistant streaming text... │ live (re-renders, ~50ms debounce)
29
+ │ │
30
+ │ [tool_name ▸ collapsed] │ live (re-renders on tool_result)
31
+ │ │
32
+ │ Thinking... │ live, only if last msg is user
33
+ │ │
34
+ │ ╭─ Approval Required ─────────────────╮ │ live, only when pending approval
35
+ │ │ description / detail │ │
36
+ │ │ ○ Approve once │ │
37
+ │ │ ○ Approve for session │ │
38
+ │ │ ○ Reject │ │
39
+ │ ╰─────────────────────────────────────╯ │
40
+ │ │
41
+ │ [Error: message] │ live, inline thread errors
42
+ │ │
43
+ ├─────────────────────────────────────────┤
44
+ │ tokens: 1234↓ 56↑ | ctx: 12% | $0.02 │ static-ish, updates after each turn
45
+ ├─────────────────────────────────────────┤
46
+ │ /clear — Clear conversation... │ dynamic, shown when typing /
47
+ │ /quit — Exit ProtoAgent │
48
+ ├─────────────────────────────────────────┤
49
+ │ ⠹ Running read_file... │ dynamic, shown while loading
50
+ ├─────────────────────────────────────────┤
51
+ │ ╭─────────────────────────────────────╮ │
52
+ │ │ > [text input cursor ] │ │ always visible when initialized
53
+ │ ╰─────────────────────────────────────╯ │
54
+ ├─────────────────────────────────────────┤
55
+ │ Session saved. Resume with: │ one-shot, shown on /quit
56
+ │ protoagent --session abc12345 │
57
+ └─────────────────────────────────────────┘
58
+ */
59
+ import { useState, useEffect, useCallback, useRef } from 'react';
60
+ import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
61
+ import { LeftBar } from './components/LeftBar.js';
11
62
  import { TextInput, Select, PasswordInput } from '@inkjs/ui';
12
- import BigText from 'ink-big-text';
13
63
  import { OpenAI } from 'openai';
14
64
  import { readConfig, writeConfig, resolveApiKey } from './config.js';
15
65
  import { loadRuntimeConfig } from './runtime-config.js';
@@ -21,117 +71,105 @@ import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, gener
21
71
  import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
22
72
  import { initializeMcp, closeMcp } from './mcp.js';
23
73
  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;
74
+ // ─── Scrollback helpers ───
75
+ // These functions append text to the permanent scrollback buffer via the
76
+ // <Static> component. Ink flushes new Static items within its own render
77
+ // cycle, so there are no timing issues with write()/log-update.
78
+ function printBanner(addStatic) {
79
+ const green = '\x1b[38;2;9;164;105m';
80
+ const reset = '\x1b[0m';
81
+ addStatic([
82
+ '',
83
+ `${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
84
+ `${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
85
+ '',
86
+ ].join('\n'));
97
87
  }
98
- function normalizeMessageSpacing(message) {
99
- const normalized = message.replace(/\r\n/g, '\n');
88
+ function printRuntimeHeader(addStatic, config, session, logFilePath, dangerouslyAcceptAll) {
89
+ const provider = getProvider(config.provider);
90
+ const grey = '\x1b[90m';
91
+ const reset = '\x1b[0m';
92
+ let line = `${grey}Model: ${provider?.name || config.provider} / ${config.model}`;
93
+ if (dangerouslyAcceptAll)
94
+ line += ' (auto-approve all)';
95
+ if (session)
96
+ line += ` | Session: ${session.id.slice(0, 8)}`;
97
+ line += reset;
98
+ let text = `${line}\n`;
99
+ if (logFilePath) {
100
+ text += `${grey}Debug logs: ${logFilePath}${reset}\n`;
101
+ }
102
+ text += '\n';
103
+ addStatic(text);
104
+ }
105
+ function normalizeTranscriptText(text) {
106
+ const normalized = text.replace(/\r\n/g, '\n');
100
107
  const lines = normalized.split('\n');
101
- while (lines.length > 0 && lines[0].trim() === '') {
108
+ while (lines.length > 0 && lines[0].trim() === '')
102
109
  lines.shift();
103
- }
104
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
110
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
105
111
  lines.pop();
106
- }
107
112
  return lines.join('\n');
108
113
  }
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;
114
+ function printMessageToScrollback(addStatic, role, text) {
115
+ const normalized = normalizeTranscriptText(text);
116
+ if (!normalized) {
117
+ addStatic('\n');
118
+ return;
116
119
  }
117
- return null;
120
+ if (role === 'user') {
121
+ addStatic(`\x1b[32m>\x1b[0m ${normalized}\n`);
122
+ return;
123
+ }
124
+ addStatic(`${normalized}\n\n`);
125
+ }
126
+ function replayMessagesToScrollback(addStatic, messages) {
127
+ for (const message of messages) {
128
+ const msgAny = message;
129
+ if (message.role === 'system')
130
+ continue;
131
+ if (message.role === 'user' && typeof message.content === 'string') {
132
+ printMessageToScrollback(addStatic, 'user', message.content);
133
+ continue;
134
+ }
135
+ if (message.role === 'assistant' && typeof message.content === 'string' && message.content.trim().length > 0) {
136
+ printMessageToScrollback(addStatic, 'assistant', message.content);
137
+ continue;
138
+ }
139
+ if (message.role === 'tool') {
140
+ const toolName = msgAny.name || 'tool';
141
+ const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
142
+ addStatic(`\x1b[2m▶ ${toolName}: ${compact}\x1b[0m\n`);
143
+ }
144
+ }
145
+ if (messages.length > 0) {
146
+ addStatic('\n');
147
+ }
148
+ }
149
+ // Returns only the last N displayable lines of text so the live streaming box
150
+ // never grows taller than the terminal, preventing Ink's clearTerminal wipe.
151
+ const STREAMING_RESERVED_ROWS = 3; // usage bar + spinner + input line
152
+ function clipToRows(text, terminalRows) {
153
+ const maxLines = Math.max(1, terminalRows - STREAMING_RESERVED_ROWS);
154
+ const lines = text.split('\n');
155
+ if (lines.length <= maxLines)
156
+ return text;
157
+ return lines.slice(lines.length - maxLines).join('\n');
118
158
  }
119
159
  // ─── Available slash commands ───
120
160
  const SLASH_COMMANDS = [
121
161
  { 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
162
  { name: '/help', description: 'Show all available commands' },
125
163
  { name: '/quit', description: 'Exit ProtoAgent' },
164
+ { name: '/exit', description: 'Alias for /quit' },
126
165
  ];
127
166
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
128
167
  const HELP_TEXT = [
129
168
  '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',
169
+ ' /clear - Clear conversation and start fresh',
170
+ ' /help - Show this help',
171
+ ' /quit - Exit ProtoAgent',
172
+ ' /exit - Alias for /quit',
135
173
  ].join('\n');
136
174
  function buildClient(config) {
137
175
  const provider = getProvider(config.provider);
@@ -194,13 +232,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
194
232
  { label: sessionApprovalLabel, value: 'approve_session' },
195
233
  { label: 'Reject', value: 'reject' },
196
234
  ];
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) }) })] }));
235
+ 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
236
  };
199
237
  /** Cost/usage display in the status bar. */
200
238
  const UsageDisplay = ({ usage, totalCost }) => {
201
239
  if (!usage && totalCost === 0)
202
240
  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)] }))] }));
241
+ return (_jsxs(Box, { marginTop: 1, children: [usage && (_jsxs(Box, { children: [_jsxs(Box, { backgroundColor: "#042f2e", paddingX: 1, children: [_jsx(Text, { color: "black", children: "tokens: " }), _jsxs(Text, { color: "black", bold: true, children: [usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191"] })] }), _jsxs(Box, { backgroundColor: "#064e3b", paddingX: 1, children: [_jsx(Text, { color: "black", children: "ctx: " }), _jsxs(Text, { color: "black", bold: true, children: [usage.contextPercent.toFixed(0), "%"] })] })] })), _jsxs(Box, { backgroundColor: "#042f2e", paddingX: 1, children: [_jsx(Text, { color: "black", children: "cost: " }), _jsxs(Text, { color: "black", bold: true, children: ["$", totalCost.toFixed(4)] })] })] }));
204
242
  };
205
243
  /** Inline setup wizard — shown when no config exists. */
206
244
  const InlineSetup = ({ onComplete }) => {
@@ -240,10 +278,35 @@ const InlineSetup = ({ onComplete }) => {
240
278
  export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
241
279
  const { exit } = useApp();
242
280
  const { stdout } = useStdout();
281
+ const terminalRows = stdout?.rows ?? 24;
282
+ // ─── Static scrollback state ───
283
+ // Each item appended here is rendered once by <Static> and permanently
284
+ // flushed to the terminal scrollback by Ink, within its own render cycle.
285
+ // Using <Static> items is important to avoid re-rendering issues, which hijack
286
+ // scrollback and copying when new AI message streams are coming in.
287
+ //
288
+ // staticCounterRef keeps ID generation local to this component instance,
289
+ // making it immune to Strict Mode double-invoke, HMR counter drift, and
290
+ // collisions if multiple App instances ever coexist.
291
+ const staticCounterRef = useRef(0);
292
+ const [staticItems, setStaticItems] = useState([]);
293
+ const addStatic = useCallback((text) => {
294
+ staticCounterRef.current += 1;
295
+ const id = `s${staticCounterRef.current}`;
296
+ setStaticItems((prev) => [...prev, { id, text }]);
297
+ }, []);
243
298
  // Core state
244
299
  const [config, setConfig] = useState(null);
245
300
  const [completionMessages, setCompletionMessages] = useState([]);
246
301
  const [inputText, setInputText] = useState('');
302
+ // isStreaming: true while the assistant is producing tokens.
303
+ // streamingText: the live in-progress token buffer shown in the dynamic Ink
304
+ // frame while the response streams. Cleared to '' at done and flushed to
305
+ // <Static> as a permanent scrollback item. Keeping it in React state (not a
306
+ // ref) is safe because the Ink frame height does NOT change as tokens arrive —
307
+ // the streaming box is always 1+ lines tall while loading=true.
308
+ const [isStreaming, setIsStreaming] = useState(false);
309
+ const [streamingText, setStreamingText] = useState('');
247
310
  const [loading, setLoading] = useState(false);
248
311
  const [error, setError] = useState(null);
249
312
  const [helpMessage, setHelpMessage] = useState(null);
@@ -253,18 +316,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
253
316
  const [logFilePath, setLogFilePath] = useState(null);
254
317
  // Input reset key — incremented on submit to force TextInput remount and clear
255
318
  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
319
  // Approval state
269
320
  const [pendingApproval, setPendingApproval] = useState(null);
270
321
  // Usage state
@@ -279,12 +330,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
279
330
  const [quittingSession, setQuittingSession] = useState(null);
280
331
  // OpenAI client ref (stable across renders)
281
332
  const clientRef = useRef(null);
282
- // Track current assistant message being built in the event handler
283
333
  const assistantMessageRef = useRef(null);
284
334
  // Abort controller for cancelling the current completion
285
335
  const abortControllerRef = useRef(null);
286
- // Debounce timer for text_delta renders (~50ms batching)
287
- const textFlushTimerRef = useRef(null);
336
+ const didPrintIntroRef = useRef(false);
337
+ const printedThreadErrorIdsRef = useRef(new Set());
338
+ const printedLogPathRef = useRef(null);
288
339
  // ─── Post-config initialization (reused after inline setup) ───
289
340
  const initializeWithConfig = useCallback(async (loadedConfig) => {
290
341
  setConfig(loadedConfig);
@@ -301,6 +352,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
301
352
  setTodosForSession(loadedSession.id, loadedSession.todos);
302
353
  setSession(loadedSession);
303
354
  setCompletionMessages(loadedSession.completionMessages);
355
+ if (!didPrintIntroRef.current) {
356
+ printBanner(addStatic);
357
+ printRuntimeHeader(addStatic, loadedConfig, loadedSession, logFilePath, dangerouslyAcceptAll);
358
+ replayMessagesToScrollback(addStatic, loadedSession.completionMessages);
359
+ didPrintIntroRef.current = true;
360
+ }
304
361
  }
305
362
  else {
306
363
  setError(`Session "${sessionId}" not found. Starting a new session.`);
@@ -314,10 +371,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
314
371
  clearTodos(newSession.id);
315
372
  newSession.completionMessages = initialCompletionMessages;
316
373
  setSession(newSession);
374
+ if (!didPrintIntroRef.current) {
375
+ printBanner(addStatic);
376
+ printRuntimeHeader(addStatic, loadedConfig, newSession, logFilePath, dangerouslyAcceptAll);
377
+ didPrintIntroRef.current = true;
378
+ }
317
379
  }
318
380
  setNeedsSetup(false);
319
381
  setInitialized(true);
320
- }, [sessionId]);
382
+ }, [dangerouslyAcceptAll, logFilePath, sessionId, addStatic]);
321
383
  // ─── Initialization ───
322
384
  useEffect(() => {
323
385
  if (!loading) {
@@ -330,17 +392,26 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
330
392
  return () => clearInterval(interval);
331
393
  }, [loading]);
332
394
  useEffect(() => {
333
- if (!stdout)
395
+ if (error) {
396
+ addStatic(`\x1b[31mError: ${error}\x1b[0m\n\n`);
397
+ }
398
+ }, [error, addStatic]);
399
+ useEffect(() => {
400
+ if (!didPrintIntroRef.current || !logFilePath || printedLogPathRef.current === logFilePath) {
334
401
  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]);
402
+ }
403
+ printedLogPathRef.current = logFilePath;
404
+ addStatic(`Debug logs: ${logFilePath}\n\n`);
405
+ }, [logFilePath, addStatic]);
406
+ useEffect(() => {
407
+ for (const threadError of threadErrors) {
408
+ if (threadError.transient || printedThreadErrorIdsRef.current.has(threadError.id)) {
409
+ continue;
410
+ }
411
+ printedThreadErrorIdsRef.current.add(threadError.id);
412
+ addStatic(`\x1b[31mError: ${threadError.message}\x1b[0m\n\n`);
413
+ }
414
+ }, [threadErrors, addStatic]);
344
415
  useEffect(() => {
345
416
  const init = async () => {
346
417
  // Set log level and initialize log file
@@ -418,7 +489,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
418
489
  setLastUsage(null);
419
490
  setTotalCost(0);
420
491
  setThreadErrors([]);
421
- setExpandedMessages(new Set());
422
492
  if (session) {
423
493
  const newSession = createSession(config.model, config.provider);
424
494
  clearTodos(session.id);
@@ -429,13 +499,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
429
499
  });
430
500
  return true;
431
501
  case '/expand':
432
- // Expand all collapsed messages
433
- const allIndices = new Set(completionMessages.map((_, i) => i));
434
- setExpandedMessages(allIndices);
435
- return true;
436
502
  case '/collapse':
437
- // Collapse all messages
438
- setExpandedMessages(new Set());
503
+ // expand/collapse removed — transcript lives in scrollback
439
504
  return true;
440
505
  case '/help':
441
506
  setHelpMessage(HELP_TEXT);
@@ -461,13 +526,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
461
526
  setInputText('');
462
527
  setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
463
528
  setLoading(true);
529
+ setIsStreaming(false);
530
+ setStreamingText('');
464
531
  setError(null);
465
532
  setHelpMessage(null);
466
533
  setThreadErrors([]);
467
- // Add user message to completion messages IMMEDIATELY for real-time UI display
534
+ // Reset turn tracking
535
+ assistantMessageRef.current = null;
536
+ // Print the user message directly to scrollback so it is selectable/copyable.
537
+ // We still push it into completionMessages for session saving.
468
538
  const userMessage = { role: 'user', content: trimmed };
539
+ printMessageToScrollback(addStatic, 'user', trimmed);
469
540
  setCompletionMessages((prev) => [...prev, userMessage]);
470
- // Reset assistant message tracker
541
+ // Reset assistant message tracker (streamed indices were reset above)
471
542
  assistantMessageRef.current = null;
472
543
  try {
473
544
  const pricing = getModelPricing(config.provider, config.model);
@@ -476,33 +547,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
476
547
  abortControllerRef.current = new AbortController();
477
548
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
478
549
  switch (event.type) {
479
- case 'text_delta':
480
- // Update the current assistant message in completionMessages
550
+ case 'text_delta': {
551
+ // Accumulate tokens into streamingText React state — shown live in
552
+ // the dynamic Ink frame. The frame height stays constant (spinner +
553
+ // streaming box + input) so setState here does NOT trigger
554
+ // clearTerminal. At 'done' the full text is flushed to <Static>.
481
555
  if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
482
- // First text delta create the assistant message immediately
556
+ // First text delta of this turn: initialise ref, show streaming indicator.
483
557
  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
- });
558
+ const idx = completionMessages.length + 1;
559
+ assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
560
+ setIsStreaming(true);
561
+ setStreamingText(event.content || '');
562
+ setCompletionMessages((prev) => [...prev, assistantMsg]);
488
563
  }
489
564
  else {
490
- // Subsequent deltas — accumulate in ref, debounce the render (~50ms)
565
+ // Subsequent deltas — append to ref AND to React state for live display.
491
566
  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
- }
567
+ setStreamingText((prev) => prev + (event.content || ''));
504
568
  }
505
569
  break;
570
+ }
506
571
  case 'sub_agent_iteration':
507
572
  if (event.subAgentTool) {
508
573
  const { tool, status } = event.subAgentTool;
@@ -518,6 +583,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
518
583
  if (event.toolCall) {
519
584
  const toolCall = event.toolCall;
520
585
  setActiveTool(toolCall.name);
586
+ // If the model streamed some text before invoking this tool,
587
+ // flush it to <Static> now. Without this, streamingText is
588
+ // never cleared — the 'done' handler only flushes streaming_text
589
+ // when the turn ends with plain text, not with tool calls.
590
+ if (assistantMessageRef.current?.kind === 'streaming_text') {
591
+ const precedingText = assistantMessageRef.current.message.content || '';
592
+ if (precedingText) {
593
+ addStatic(`${normalizeTranscriptText(precedingText)}\n\n`);
594
+ }
595
+ setIsStreaming(false);
596
+ setStreamingText('');
597
+ assistantMessageRef.current = null;
598
+ }
521
599
  // Track the tool call in the ref WITHOUT triggering a render.
522
600
  // The render will happen when tool_result arrives.
523
601
  const existingRef = assistantMessageRef.current;
@@ -564,16 +642,20 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
564
642
  if (event.toolCall) {
565
643
  const toolCall = event.toolCall;
566
644
  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
645
+ // Write the tool summary immediately at this point loading is
646
+ // still true but the frame height is stable (spinner + input box).
647
+ // The next state change (setActiveTool(null)) doesn't affect
648
+ // frame height so write() restores the correct frame.
649
+ const compactResult = (toolCall.result || '')
650
+ .replace(/\s+/g, ' ')
651
+ .trim()
652
+ .slice(0, 180);
653
+ addStatic(`\x1b[2m▶ ${toolCall.name}: ${compactResult}\x1b[0m\n`);
654
+ // Flush the assistant message + tool result into completionMessages
655
+ // for session saving.
574
656
  setCompletionMessages((prev) => {
575
657
  const updated = [...prev];
576
- // Sync assistant message (may have new tool_calls since last render)
658
+ // Sync assistant message
577
659
  if (assistantMessageRef.current) {
578
660
  updated[assistantMessageRef.current.index] = {
579
661
  ...assistantMessageRef.current.message,
@@ -628,13 +710,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
628
710
  }
629
711
  break;
630
712
  case 'iteration_done':
631
- assistantMessageRef.current = null;
713
+ if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
714
+ assistantMessageRef.current = null;
715
+ }
632
716
  break;
633
717
  case 'done':
634
- // Clear any pending text delta timer
635
- if (textFlushTimerRef.current) {
636
- clearTimeout(textFlushTimerRef.current);
637
- textFlushTimerRef.current = null;
718
+ if (assistantMessageRef.current?.kind === 'streaming_text') {
719
+ const finalRef = assistantMessageRef.current;
720
+ // Flush the complete streamed text to <Static> (permanent scrollback),
721
+ // then clear the live streaming state from the dynamic Ink frame.
722
+ const normalized = normalizeTranscriptText(finalRef.message.content || '');
723
+ if (normalized) {
724
+ addStatic(`${normalized}\n\n`);
725
+ }
726
+ setIsStreaming(false);
727
+ setStreamingText('');
728
+ setCompletionMessages((prev) => {
729
+ const updated = [...prev];
730
+ updated[finalRef.index] = { ...finalRef.message };
731
+ return updated;
732
+ });
733
+ assistantMessageRef.current = null;
638
734
  }
639
735
  setActiveTool(null);
640
736
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
@@ -662,7 +758,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
662
758
  finally {
663
759
  setLoading(false);
664
760
  }
665
- }, [loading, config, completionMessages, session, handleSlashCommand, expandLatestMessage]);
761
+ }, [loading, config, completionMessages, session, handleSlashCommand, addStatic]);
666
762
  // ─── Keyboard shortcuts ───
667
763
  useInput((input, key) => {
668
764
  if (key.ctrl && input === 'c') {
@@ -674,25 +770,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
674
770
  }
675
771
  });
676
772
  // ─── 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) => {
773
+ 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
774
  initializeWithConfig(newConfig).catch((err) => {
689
775
  setError(`Initialization failed: ${err.message}`);
690
776
  });
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] })] }))] }));
777
+ } })), 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) => {
778
+ pendingApproval.resolve(response);
779
+ setPendingApproval(null);
780
+ } })), 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] })] }))] }));
698
781
  };
@@ -46,21 +46,22 @@ async function sleepWithAbort(delayMs, abortSignal) {
46
46
  abortSignal.addEventListener('abort', onAbort, { once: true });
47
47
  });
48
48
  }
49
- function appendStreamingFragment(current, fragment) {
49
+ /** @internal exported for unit testing only */
50
+ export function appendStreamingFragment(current, fragment) {
50
51
  if (!fragment)
51
52
  return current;
52
53
  if (!current)
53
54
  return fragment;
55
+ // Some providers resend the full accumulated value instead of a delta.
56
+ // These two guards handle that case without corrupting normal incremental deltas.
54
57
  if (current === fragment)
55
58
  return current;
56
59
  if (fragment.startsWith(current))
57
60
  return fragment;
58
- const maxOverlap = Math.min(current.length, fragment.length);
59
- for (let overlap = maxOverlap; overlap > 0; overlap--) {
60
- if (current.endsWith(fragment.slice(0, overlap))) {
61
- return current + fragment.slice(overlap);
62
- }
63
- }
61
+ // Normal case: incremental delta, just append.
62
+ // The previous partial-overlap loop was removed because it caused false-positive
63
+ // deduplication: short JSON tokens (e.g. `", "`) would coincidentally match the
64
+ // tail of `current`, silently stripping characters from valid argument payloads.
64
65
  return current + fragment;
65
66
  }
66
67
  function collapseRepeatedString(value) {
@@ -347,7 +348,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
347
348
  if (chunk.usage) {
348
349
  actualUsage = chunk.usage;
349
350
  }
350
- // Stream text content
351
+ // Stream text content (and return to UI for immediate display via onEvent)
351
352
  if (delta?.content) {
352
353
  streamedContent += delta.content;
353
354
  assistantMessage.content = streamedContent;
@@ -355,7 +356,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
355
356
  onEvent({ type: 'text_delta', content: delta.content });
356
357
  }
357
358
  }
358
- // Accumulate tool calls
359
+ // Accumulate tool calls across stream chunks
359
360
  if (delta?.tool_calls) {
360
361
  hasToolCalls = true;
361
362
  for (const tc of delta.tool_calls) {
@@ -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
+ };
@@ -67,7 +67,7 @@ export async function generateSystemPrompt() {
67
67
  const toolDescriptions = generateToolDescriptions();
68
68
  const skillsSection = buildSkillsCatalogSection(skills);
69
69
  return `You are ProtoAgent, a coding assistant with file system and shell command capabilities.
70
- Your job is to help the user complete coding tasks in their project.
70
+ Your job is to help the user complete coding tasks in their project. You must be absolutely careful and diligent in your work, and follow all guidelines to the letter. Always prefer thoroughness and correctness over speed. Never cut corners.
71
71
 
72
72
  PROJECT CONTEXT
73
73
 
@@ -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.
@@ -121,8 +121,11 @@ FILE OPERATIONS:
121
121
  - ALWAYS use read_file before editing to get exact content.
122
122
  - NEVER write over existing files unless explicitly asked — use edit_file instead.
123
123
  - Create parent directories before creating files in them.
124
- - Use bash for package management, git, building, testing, etc.
125
- - When running interactive commands, add flags to avoid prompts (--yes, --template, etc.)
124
+ - INDENTATION: when writing new_string for edit_file, preserve the exact indentation of every line. Copy the indent character-for-character from the file. A single dropped space is a bug.
125
+ - STRICT TYPO PREVENTION: You have a tendency to drop characters or misspell words (e.g., "commands" vs "comands") when generating long code blocks. Before submitting a tool call, perform a character-by-character mental audit.
126
+ - VERIFICATION STEP: After generating a new_string, compare it against the old_string. Ensure that only the intended changes are present and that no existing words have been accidentally altered or truncated.
127
+ - NO TRUNCATION: Never truncate code or leave "..." in your tool calls. Every string must be literal and complete.
128
+ - IF edit_file FAILS: do NOT retry by guessing or reconstructing old_string from memory. Call read_file on the file first, then copy the exact text verbatim for old_string. The error output shows exactly which lines differ between your old_string and the file — read those carefully before retrying.
126
129
 
127
130
  IMPLEMENTATION STANDARDS:
128
131
  - **Thorough investigation**: Before implementing, understand the existing codebase, patterns, and related systems.
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
- import { validatePath } from '../utils/path-validation.js';
10
+ import { validatePath, getWorkingDirectory } from '../utils/path-validation.js';
11
11
  import { requestApproval } from '../utils/approval.js';
12
- import { assertReadBefore, recordRead } from '../utils/file-time.js';
12
+ import { checkReadBefore, recordRead } from '../utils/file-time.js';
13
13
  export const editFileTool = {
14
14
  type: 'function',
15
15
  function: {
@@ -32,6 +32,54 @@ export const editFileTool = {
32
32
  },
33
33
  },
34
34
  };
35
+ // ─── Path suggestion helper (mirrors read_file behaviour) ───
36
+ async function findSimilarPaths(requestedPath) {
37
+ const cwd = getWorkingDirectory();
38
+ const segments = requestedPath.split('/').filter(Boolean);
39
+ const MAX_DEPTH = 6;
40
+ const MAX_ENTRIES = 200;
41
+ const MAX_SUGGESTIONS = 3;
42
+ const candidates = [];
43
+ async function walkSegments(dir, segIndex, currentPath) {
44
+ if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
45
+ return;
46
+ const targetSegment = segments[segIndex].toLowerCase();
47
+ let entries;
48
+ try {
49
+ entries = (await fs.readdir(dir, { withFileTypes: true })).slice(0, MAX_ENTRIES).map(e => e.name);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ const isLastSegment = segIndex === segments.length - 1;
55
+ for (const entry of entries) {
56
+ if (candidates.length >= MAX_SUGGESTIONS)
57
+ break;
58
+ const entryLower = entry.toLowerCase();
59
+ if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
60
+ continue;
61
+ const entryPath = path.join(currentPath, entry);
62
+ const fullPath = path.join(dir, entry);
63
+ if (isLastSegment) {
64
+ try {
65
+ await fs.stat(fullPath);
66
+ candidates.push(entryPath);
67
+ }
68
+ catch { /* skip */ }
69
+ }
70
+ else {
71
+ try {
72
+ const stat = await fs.stat(fullPath);
73
+ if (stat.isDirectory())
74
+ await walkSegments(fullPath, segIndex + 1, entryPath);
75
+ }
76
+ catch { /* skip */ }
77
+ }
78
+ }
79
+ }
80
+ await walkSegments(cwd, 0, '');
81
+ return candidates;
82
+ }
35
83
  /** Strategy 1: Exact verbatim match (current behavior). */
36
84
  const exactReplacer = {
37
85
  name: 'exact',
@@ -245,10 +293,26 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
245
293
  if (oldString.length === 0) {
246
294
  return 'Error: old_string cannot be empty.';
247
295
  }
248
- const validated = await validatePath(filePath);
296
+ let validated;
297
+ try {
298
+ validated = await validatePath(filePath);
299
+ }
300
+ catch (err) {
301
+ if (err.message?.includes('does not exist') || err.code === 'ENOENT') {
302
+ const suggestions = await findSimilarPaths(filePath);
303
+ let msg = `File not found: '${filePath}'.`;
304
+ if (suggestions.length > 0) {
305
+ msg += ' Did you mean one of these?\n' + suggestions.map(s => ` ${s}`).join('\n');
306
+ }
307
+ return msg;
308
+ }
309
+ throw err;
310
+ }
249
311
  // Staleness guard: must have read file before editing
250
312
  if (sessionId) {
251
- assertReadBefore(sessionId, validated);
313
+ const staleError = checkReadBefore(sessionId, validated);
314
+ if (staleError)
315
+ return staleError;
252
316
  }
253
317
  const content = await fs.readFile(validated, 'utf8');
254
318
  // Use fuzzy match cascade
@@ -262,7 +326,67 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
262
326
  return `Error: found ${count} occurrence(s) of old_string (via ${strategy.name} match), but expected ${expectedReplacements}. Be more specific or set expected_replacements=${count}.`;
263
327
  }
264
328
  }
265
- return `Error: old_string not found in ${filePath}. Strategies exhausted (exact, line-trimmed, indent-flexible, whitespace-normalized, trimmed-boundary). Re-read the file and try again.`;
329
+ // Build a per-strategy diagnostic to help the model self-correct without
330
+ // requiring a full re-read. For each strategy, find the closest partial
331
+ // match and report ALL lines where it diverges (not just the first).
332
+ const searchLines = oldString.split('\n');
333
+ const contentLines = content.split('\n');
334
+ const diagnostics = [];
335
+ for (const strategy of STRATEGIES) {
336
+ // Find the window in the file that shares the most lines with oldString
337
+ let bestWindowStart = -1;
338
+ let bestMatchedLines = 0;
339
+ for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
340
+ let matched = 0;
341
+ for (let j = 0; j < searchLines.length; j++) {
342
+ const fileLine = strategy.name === 'whitespace-normalized'
343
+ ? contentLines[i + j].replace(/\s+/g, ' ').trim()
344
+ : contentLines[i + j].trim();
345
+ const searchLine = strategy.name === 'whitespace-normalized'
346
+ ? searchLines[j].replace(/\s+/g, ' ').trim()
347
+ : searchLines[j].trim();
348
+ if (fileLine === searchLine)
349
+ matched++;
350
+ }
351
+ if (matched > bestMatchedLines) {
352
+ bestMatchedLines = matched;
353
+ bestWindowStart = i;
354
+ }
355
+ }
356
+ if (bestWindowStart >= 0 && bestMatchedLines > 0 && bestMatchedLines < searchLines.length) {
357
+ // Collect all diverging lines, not just the first
358
+ const diffLines = [];
359
+ const MAX_DIFFS = 6;
360
+ for (let j = 0; j < searchLines.length && diffLines.length < MAX_DIFFS; j++) {
361
+ const fileLine = contentLines[bestWindowStart + j] ?? '(end of file)';
362
+ const searchLine = searchLines[j];
363
+ const fileNorm = strategy.name === 'whitespace-normalized'
364
+ ? fileLine.replace(/\s+/g, ' ').trim()
365
+ : fileLine.trim();
366
+ const searchNorm = strategy.name === 'whitespace-normalized'
367
+ ? searchLine.replace(/\s+/g, ' ').trim()
368
+ : searchLine.trim();
369
+ if (fileNorm !== searchNorm) {
370
+ diffLines.push(` line ${bestWindowStart + j + 1}:\n` +
371
+ ` yours: ${searchLine.trim().slice(0, 120)}\n` +
372
+ ` file: ${fileLine.trim().slice(0, 120)}`);
373
+ }
374
+ }
375
+ const truncNote = diffLines.length === MAX_DIFFS ? `\n ... (more diffs not shown)` : '';
376
+ diagnostics.push(` ${strategy.name}: ${bestMatchedLines}/${searchLines.length} lines match, ${searchLines.length - bestMatchedLines} differ:\n` +
377
+ diffLines.join('\n') + truncNote);
378
+ }
379
+ else if (bestMatchedLines === 0) {
380
+ diagnostics.push(` ${strategy.name}: no lines matched — old_string may be from a different file or heavily rewritten`);
381
+ }
382
+ else {
383
+ diagnostics.push(` ${strategy.name}: no partial match found`);
384
+ }
385
+ }
386
+ const hint = diagnostics.length > 0
387
+ ? '\nDiagnostics per strategy:\n' + diagnostics.join('\n')
388
+ : '';
389
+ return `Error: old_string not found in ${filePath}.${hint}\nDo NOT retry with a guess. Call read_file on ${filePath} first to get the exact current content, then construct old_string by copying verbatim from the file.`;
266
390
  }
267
391
  const { actual, strategy, count } = match;
268
392
  // Request approval
@@ -108,46 +108,29 @@ export async function readFile(filePath, offset = 0, limit = 2000, sessionId) {
108
108
  const start = Math.max(0, offset);
109
109
  const maxLines = Math.max(0, limit);
110
110
  const lines = [];
111
- let totalLines = 0;
112
111
  const stream = createReadStream(validated, { encoding: 'utf8' });
113
112
  const lineReader = readline.createInterface({
114
113
  input: stream,
115
114
  crlfDelay: Infinity,
116
115
  });
117
116
  try {
117
+ let lineIndex = 0;
118
118
  for await (const line of lineReader) {
119
- if (totalLines >= start && lines.length < maxLines) {
119
+ if (lineIndex >= start && lines.length < maxLines) {
120
120
  lines.push(line);
121
121
  }
122
- totalLines++;
123
- }
124
- const stats = await fs.stat(validated);
125
- if (stats.size === 0) {
126
- totalLines = 0;
127
- }
128
- else if (lines.length === 0 && totalLines === 0) {
129
- totalLines = 1;
122
+ lineIndex++;
130
123
  }
131
124
  }
132
125
  finally {
133
126
  lineReader.close();
134
127
  stream.destroy();
135
128
  }
136
- const end = Math.min(totalLines, start + lines.length);
137
- // Add line numbers (1-based)
138
- const numbered = lines.map((line, i) => {
139
- const lineNum = String(start + i + 1).padStart(5, ' ');
140
- // Truncate very long lines
141
- const truncated = line.length > 2000 ? line.slice(0, 2000) + '... (truncated)' : line;
142
- return `${lineNum} | ${truncated}`;
143
- });
129
+ // Truncate very long individual lines but don't reformat content
130
+ const slice = lines.map(line => line.length > 2000 ? line.slice(0, 2000) + '... (truncated)' : line);
144
131
  // Record successful read for staleness tracking
145
132
  if (sessionId) {
146
133
  recordRead(sessionId, validated);
147
134
  }
148
- const rangeLabel = lines.length === 0
149
- ? 'none'
150
- : `${Math.min(start + 1, totalLines)}-${end}`;
151
- const header = `File: ${filePath} (${totalLines} lines total, showing ${rangeLabel})`;
152
- return `${header}\n${numbered.join('\n')}`;
135
+ return slice.join('\n');
153
136
  }
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { validatePath } from '../utils/path-validation.js';
7
7
  import { requestApproval } from '../utils/approval.js';
8
+ import { recordRead } from '../utils/file-time.js';
8
9
  export const writeFileTool = {
9
10
  type: 'function',
10
11
  function: {
@@ -49,5 +50,10 @@ export async function writeFile(filePath, content, sessionId) {
49
50
  await fs.rm(tmpPath, { force: true }).catch(() => undefined);
50
51
  }
51
52
  const lines = content.split('\n').length;
53
+ // Record the write as a read so a subsequent edit_file on this file doesn't
54
+ // immediately fail the staleness guard with "you must read first".
55
+ if (sessionId) {
56
+ recordRead(sessionId, validated);
57
+ }
52
58
  return `Successfully wrote ${lines} lines to ${filePath}`;
53
59
  }
@@ -13,34 +13,43 @@ export function recordRead(sessionId, absolutePath) {
13
13
  readTimes.set(`${sessionId}:${absolutePath}`, Date.now());
14
14
  }
15
15
  /**
16
- * Assert that a file was previously read and hasn't changed on disk since.
17
- * Throws if the file was never read or has been modified.
16
+ * Check that a file was previously read and hasn't changed on disk since.
17
+ * Returns an error string if the check fails, or null if all is well.
18
+ * Use this instead of assertReadBefore so staleness errors surface as normal
19
+ * tool return values rather than exceptions that get swallowed into a generic
20
+ * "Error executing edit_file: ..." message.
18
21
  */
19
- export function assertReadBefore(sessionId, absolutePath) {
22
+ export function checkReadBefore(sessionId, absolutePath) {
20
23
  const key = `${sessionId}:${absolutePath}`;
21
24
  const lastRead = readTimes.get(key);
22
25
  if (!lastRead) {
23
- throw new Error(`You must read '${absolutePath}' before editing it. Call read_file first.`);
26
+ return `You must read '${absolutePath}' before editing it. Call read_file first.`;
24
27
  }
25
28
  try {
26
29
  const mtime = fs.statSync(absolutePath).mtimeMs;
27
30
  if (mtime > lastRead + 100) {
28
- // Clear stale entry so the error message stays accurate
31
+ // Clear stale entry so the error message stays accurate on retry
29
32
  readTimes.delete(key);
30
- throw new Error(`'${absolutePath}' has changed on disk since you last read it. Re-read it before editing.`);
33
+ return `'${absolutePath}' has changed on disk since you last read it. Re-read it before editing.`;
31
34
  }
32
35
  }
33
36
  catch (err) {
34
37
  if (err.code === 'ENOENT') {
35
38
  readTimes.delete(key);
36
- throw new Error(`'${absolutePath}' no longer exists on disk.`);
37
- }
38
- // Re-throw our own errors
39
- if (err.message.includes('has changed on disk') || err.message.includes('must read')) {
40
- throw err;
39
+ return `'${absolutePath}' no longer exists on disk.`;
41
40
  }
42
41
  // Ignore other stat errors — don't block edits on stat failures
43
42
  }
43
+ return null;
44
+ }
45
+ /**
46
+ * @deprecated Use checkReadBefore instead — it returns a string rather than
47
+ * throwing, so the error surfaces cleanly as a tool result.
48
+ */
49
+ export function assertReadBefore(sessionId, absolutePath) {
50
+ const err = checkReadBefore(sessionId, absolutePath);
51
+ if (err)
52
+ throw new Error(err);
44
53
  }
45
54
  /**
46
55
  * Clear all read-time entries for a session (e.g. on session end).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",