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 +0 -1
- package/dist/App.js +215 -123
- package/dist/agentic-loop.js +182 -31
- package/dist/cli.js +3 -3
- package/dist/components/FormattedMessage.js +2 -2
- package/dist/config.js +76 -22
- package/dist/mcp.js +15 -0
- package/dist/providers.js +8 -15
- package/dist/sessions.js +13 -3
- package/dist/skills.js +2 -1
- package/dist/sub-agent.js +138 -20
- package/dist/system-prompt.js +47 -1
- package/dist/tools/bash.js +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/utils/approval.js +8 -8
- package/dist/utils/cost-tracker.js +9 -3
- package/dist/utils/file-time.js +0 -9
- package/dist/utils/format-message.js +49 -23
- package/package.json +23 -3
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
|
-
│ /
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
84
|
+
if (dangerouslySkipPermissions)
|
|
91
85
|
line += ' (auto-approve all)';
|
|
92
86
|
if (session)
|
|
93
|
-
line += ` | Session: ${session.id
|
|
94
|
-
|
|
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
|
-
|
|
91
|
+
lines.push(_jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }, "log"));
|
|
97
92
|
}
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
119
|
+
addStatic(_jsxs(Text, { children: [_jsx(Text, { color: "green", children: '>' }), " ", normalized, '\n'] }));
|
|
118
120
|
return;
|
|
119
121
|
}
|
|
120
|
-
|
|
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(
|
|
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('
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 (
|
|
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
|
-
|
|
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 = ({
|
|
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((
|
|
340
|
+
const addStatic = useCallback((node) => {
|
|
290
341
|
staticCounterRef.current += 1;
|
|
291
342
|
const id = `s${staticCounterRef.current}`;
|
|
292
|
-
setStaticItems((prev) => [...prev, { id,
|
|
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,
|
|
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,
|
|
428
|
+
printRuntimeHeader(addStatic, loadedConfig, newSession, dangerouslySkipPermissions);
|
|
373
429
|
didPrintIntroRef.current = true;
|
|
374
430
|
}
|
|
375
431
|
}
|
|
376
432
|
setNeedsSetup(false);
|
|
377
433
|
setInitialized(true);
|
|
378
|
-
}, [
|
|
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(
|
|
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(
|
|
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
|
-
|
|
419
|
-
setLogFilePath(logPath);
|
|
467
|
+
initLogFile();
|
|
420
468
|
logger.info(`ProtoAgent started with log level: ${logLevel}`);
|
|
421
|
-
logger.info(`Log file: ${
|
|
469
|
+
logger.info(`Log file: ${logger.getLogFilePath()}`);
|
|
422
470
|
}
|
|
423
471
|
}
|
|
424
472
|
// Set global approval mode
|
|
425
|
-
if (
|
|
426
|
-
|
|
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
|
-
|
|
548
|
-
//
|
|
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
|
-
//
|
|
553
|
-
const
|
|
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
|
|
562
|
-
assistantMessageRef.current.message.content +=
|
|
563
|
-
|
|
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(
|
|
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
|
|
584
|
-
//
|
|
585
|
-
//
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
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(
|
|
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
|
-
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
if (
|
|
720
|
-
|
|
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.
|
|
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: "
|
|
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] })] }))] }));
|