protoagent 0.1.13 → 0.1.15
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 +1 -4
- package/dist/App.js +77 -442
- package/dist/agentic-loop/errors.js +198 -0
- package/dist/agentic-loop/executor.js +108 -0
- package/dist/agentic-loop/stream.js +109 -0
- package/dist/agentic-loop.js +67 -593
- package/dist/components/ApprovalPrompt.js +18 -0
- package/dist/components/CommandFilter.js +19 -0
- package/dist/components/InlineSetup.js +33 -0
- package/dist/components/UsageDisplay.js +10 -0
- package/dist/config.js +52 -51
- package/dist/hooks/useAgentEventHandler.js +356 -0
- package/dist/mcp.js +3 -0
- package/dist/runtime-config.js +64 -33
- package/dist/skills.js +3 -1
- package/dist/sub-agent.js +11 -16
- package/dist/tools/bash.js +37 -11
- package/dist/tools/edit-file.js +8 -49
- package/dist/tools/read-file.js +3 -66
- package/dist/tools/search-files.js +70 -12
- package/dist/tools/webfetch.js +77 -62
- package/dist/tools/write-file.js +39 -3
- package/dist/utils/approval.js +2 -0
- package/dist/utils/compactor.js +2 -1
- package/dist/utils/cost-tracker.js +5 -2
- package/dist/utils/format-message.js +13 -0
- package/dist/utils/logger.js +16 -3
- package/dist/utils/path-suggestions.js +74 -0
- package/dist/utils/path-validation.js +2 -5
- package/dist/utils/tool-display.js +53 -0
- package/package.json +11 -4
- package/dist/components/CollapsibleBox.js +0 -27
- package/dist/components/ConfigDialog.js +0 -42
- package/dist/components/ConsolidatedToolMessage.js +0 -34
- package/dist/components/FormattedMessage.js +0 -170
package/dist/App.js
CHANGED
|
@@ -6,32 +6,27 @@ Renders the chat loop, tool call feedback, approval prompts,
|
|
|
6
6
|
and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
|
|
7
7
|
this file is purely presentation + state wiring.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
ACTUAL UI Layout:
|
|
10
10
|
┌─────────────────────────────────────────┐
|
|
11
|
-
│ ProtoAgent (
|
|
12
|
-
│ Model:
|
|
11
|
+
│ ProtoAgent (ASCII block logo) │ static, rendered once
|
|
12
|
+
│ Model: Provider / model | Session: id │ static header
|
|
13
13
|
│ Debug logs: /path/to/log │ static, if --log-level set
|
|
14
|
+
│ Config file: /path/to/config │ static, if config exists
|
|
15
|
+
│ MCPs: server1, server2 │ static, if MCPs connected
|
|
14
16
|
├─────────────────────────────────────────┤
|
|
15
17
|
│ │
|
|
16
|
-
│
|
|
18
|
+
│ Archived messages (Static scrollback): │
|
|
19
|
+
│ > user message │
|
|
20
|
+
│ assistant reply text │
|
|
21
|
+
│ ▶ tool_name: result preview... │
|
|
17
22
|
│ │
|
|
18
|
-
|
|
23
|
+
├ ─ ─ ─ ─ ─ live boundary ─ ─ ─ ─ ─ ─ ─ ─ ┤
|
|
19
24
|
│ │
|
|
20
|
-
│ assistant
|
|
25
|
+
│ assistant streaming text...▍ │ live (re-renders per token)
|
|
21
26
|
│ │
|
|
22
|
-
│
|
|
27
|
+
│ ⠹ Running read_file... │ live, spinner + active tool
|
|
23
28
|
│ │
|
|
24
|
-
│
|
|
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
|
|
29
|
+
│ ╭─ Approval Required ─────────────────╮ │ live, when pending approval
|
|
35
30
|
│ │ description / detail │ │
|
|
36
31
|
│ │ ○ Approve once │ │
|
|
37
32
|
│ │ ○ Approve for session │ │
|
|
@@ -41,9 +36,9 @@ Here's how the terminal UI is laid out (showcasing all options at once for demon
|
|
|
41
36
|
│ [Error: message] │ live, inline thread errors
|
|
42
37
|
│ │
|
|
43
38
|
├─────────────────────────────────────────┤
|
|
44
|
-
│ tokens: 1234↓ 56↑ | ctx: 12% | $0.02 │
|
|
39
|
+
│ tokens: 1234↓ 56↑ | ctx: 12% | $0.02 │ live, updates each turn
|
|
45
40
|
├─────────────────────────────────────────┤
|
|
46
|
-
│ /quit — Exit ProtoAgent │ dynamic, shown when typing /
|
|
41
|
+
│ /quit — Exit ProtoAgent │ dynamic, shown when typing / to show available commands
|
|
47
42
|
├─────────────────────────────────────────┤
|
|
48
43
|
│ ⠹ Running read_file... │ dynamic, shown while loading
|
|
49
44
|
├─────────────────────────────────────────┤
|
|
@@ -54,13 +49,22 @@ Here's how the terminal UI is laid out (showcasing all options at once for demon
|
|
|
54
49
|
│ Session saved. Resume with: │ one-shot, shown on /quit
|
|
55
50
|
│ protoagent --session abc12345 │
|
|
56
51
|
└─────────────────────────────────────────┘
|
|
52
|
+
|
|
53
|
+
NOTES:
|
|
54
|
+
- System prompt is NOT displayed (filtered out in replay)
|
|
55
|
+
- Tool results are flat text, not collapsible
|
|
56
|
+
- "Working..." spinner shown when loading but not streaming
|
|
57
57
|
*/
|
|
58
58
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
59
59
|
import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
|
|
60
60
|
import { LeftBar } from './components/LeftBar.js';
|
|
61
|
-
import {
|
|
61
|
+
import { CommandFilter, SLASH_COMMANDS } from './components/CommandFilter.js';
|
|
62
|
+
import { ApprovalPrompt } from './components/ApprovalPrompt.js';
|
|
63
|
+
import { UsageDisplay } from './components/UsageDisplay.js';
|
|
64
|
+
import { InlineSetup } from './components/InlineSetup.js';
|
|
65
|
+
import { TextInput } from '@inkjs/ui';
|
|
62
66
|
import { OpenAI } from 'openai';
|
|
63
|
-
import { readConfig,
|
|
67
|
+
import { readConfig, resolveApiKey } from './config.js';
|
|
64
68
|
import { loadRuntimeConfig, getActiveRuntimeConfigPath } from './runtime-config.js';
|
|
65
69
|
import { getProvider, getModelPricing, getRequestDefaultParams } from './providers.js';
|
|
66
70
|
import { runAgenticLoop, initializeMessages, } from './agentic-loop.js';
|
|
@@ -70,22 +74,25 @@ import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, gener
|
|
|
70
74
|
import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
|
|
71
75
|
import { initializeMcp, closeMcp, getConnectedMcpServers } from './mcp.js';
|
|
72
76
|
import { generateSystemPrompt } from './system-prompt.js';
|
|
73
|
-
import { renderFormattedText } from './utils/format-message.js';
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
// cycle, so there are no timing issues with write()/log-update.
|
|
77
|
+
import { renderFormattedText, normalizeTranscriptText } from './utils/format-message.js';
|
|
78
|
+
import { formatToolActivity } from './utils/tool-display.js';
|
|
79
|
+
import { useAgentEventHandler } from './hooks/useAgentEventHandler.js';
|
|
80
|
+
// Render the ProtoAgent ASCII logo in brand green (#09A469)
|
|
78
81
|
function printBanner(addStatic) {
|
|
79
|
-
|
|
82
|
+
const BRAND_GREEN = '#09A469';
|
|
83
|
+
addStatic(_jsxs(Text, { children: [_jsx(Text, { color: BRAND_GREEN, 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: BRAND_GREEN, 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'] }));
|
|
80
84
|
}
|
|
85
|
+
// Display runtime metadata: model, session, debug log path, config path, and connected MCPs
|
|
81
86
|
function printRuntimeHeader(addStatic, config, session, dangerouslySkipPermissions) {
|
|
82
87
|
const provider = getProvider(config.provider);
|
|
83
|
-
let
|
|
88
|
+
let modelLine = `Model: ${provider?.name || config.provider} / ${config.model}`;
|
|
84
89
|
if (dangerouslySkipPermissions)
|
|
85
|
-
|
|
90
|
+
modelLine += ' (auto-approve all)';
|
|
86
91
|
if (session)
|
|
87
|
-
|
|
88
|
-
const lines = [
|
|
92
|
+
modelLine += ` | Session: ${session.id}`;
|
|
93
|
+
const lines = [
|
|
94
|
+
_jsx(Text, { dimColor: true, children: modelLine }, "model")
|
|
95
|
+
];
|
|
89
96
|
const logFilePath = logger.getLogFilePath();
|
|
90
97
|
if (logFilePath) {
|
|
91
98
|
lines.push(_jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }, "log"));
|
|
@@ -100,15 +107,6 @@ function printRuntimeHeader(addStatic, config, session, dangerouslySkipPermissio
|
|
|
100
107
|
}
|
|
101
108
|
addStatic(_jsxs(Text, { children: [lines.map((l, i) => _jsxs(React.Fragment, { children: [l, '\n'] }, i)), '\n'] }));
|
|
102
109
|
}
|
|
103
|
-
function normalizeTranscriptText(text) {
|
|
104
|
-
const normalized = text.replace(/\r\n/g, '\n');
|
|
105
|
-
const lines = normalized.split('\n');
|
|
106
|
-
while (lines.length > 0 && lines[0].trim() === '')
|
|
107
|
-
lines.shift();
|
|
108
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
109
|
-
lines.pop();
|
|
110
|
-
return lines.join('\n');
|
|
111
|
-
}
|
|
112
110
|
function printMessageToScrollback(addStatic, role, text) {
|
|
113
111
|
const normalized = normalizeTranscriptText(text);
|
|
114
112
|
if (!normalized) {
|
|
@@ -119,66 +117,9 @@ function printMessageToScrollback(addStatic, role, text) {
|
|
|
119
117
|
addStatic(_jsxs(Text, { children: [_jsx(Text, { color: "green", children: '>' }), " ", normalized, '\n'] }));
|
|
120
118
|
return;
|
|
121
119
|
}
|
|
122
|
-
//
|
|
120
|
+
// Fallback is assistant, render with Markdown formatting (bold, italic)
|
|
123
121
|
addStatic(_jsxs(Text, { children: [renderFormattedText(normalized), '\n'] }));
|
|
124
122
|
}
|
|
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}...`;
|
|
181
|
-
}
|
|
182
123
|
function replayMessagesToScrollback(addStatic, messages) {
|
|
183
124
|
for (const message of messages) {
|
|
184
125
|
const msgAny = message;
|
|
@@ -195,15 +136,27 @@ function replayMessagesToScrollback(addStatic, messages) {
|
|
|
195
136
|
if (message.role === 'tool') {
|
|
196
137
|
const toolName = msgAny.name || 'tool';
|
|
197
138
|
const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
198
|
-
|
|
139
|
+
// Format tool display with args if available
|
|
140
|
+
let toolDisplay = toolName;
|
|
141
|
+
if (msgAny.args) {
|
|
142
|
+
try {
|
|
143
|
+
const args = JSON.parse(msgAny.args);
|
|
144
|
+
toolDisplay = formatToolActivity(toolName, args);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// If parsing fails, use the tool name
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
addStatic(_jsxs(Text, { dimColor: true, children: ['▶ ', toolDisplay, ': ', compact, '\n'] }));
|
|
199
151
|
}
|
|
200
152
|
}
|
|
201
153
|
if (messages.length > 0) {
|
|
202
154
|
addStatic(_jsx(Text, { children: '\n' }));
|
|
203
155
|
}
|
|
204
156
|
}
|
|
205
|
-
//
|
|
206
|
-
//
|
|
157
|
+
// Limit streaming text to viewport height to prevent overflow that would
|
|
158
|
+
// trigger Ink's clearTerminal() and wipe scrollback history. Completed
|
|
159
|
+
// lines are archived to <Static>; we only show the last N visible lines.
|
|
207
160
|
const STREAMING_RESERVED_ROWS = 3; // usage bar + spinner + input line
|
|
208
161
|
function clipToRows(text, terminalRows) {
|
|
209
162
|
const maxLines = Math.max(1, terminalRows - STREAMING_RESERVED_ROWS);
|
|
@@ -212,18 +165,12 @@ function clipToRows(text, terminalRows) {
|
|
|
212
165
|
return text;
|
|
213
166
|
return lines.slice(lines.length - maxLines).join('\n');
|
|
214
167
|
}
|
|
215
|
-
// ───
|
|
216
|
-
const SLASH_COMMANDS = [
|
|
217
|
-
{ name: '/help', description: 'Show all available commands' },
|
|
218
|
-
{ name: '/quit', description: 'Exit ProtoAgent' },
|
|
219
|
-
{ name: '/exit', description: 'Alias for /quit' },
|
|
220
|
-
];
|
|
168
|
+
// ─── Spinner frames for loading indicator ───
|
|
221
169
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
170
|
+
// ─── Help text derived from slash commands ───
|
|
222
171
|
const HELP_TEXT = [
|
|
223
172
|
'Commands:',
|
|
224
|
-
|
|
225
|
-
' /quit - Exit ProtoAgent',
|
|
226
|
-
' /exit - Alias for /quit',
|
|
173
|
+
...SLASH_COMMANDS.map((cmd) => ` ${cmd.name} - ${cmd.description}`),
|
|
227
174
|
].join('\n');
|
|
228
175
|
function buildClient(config) {
|
|
229
176
|
const provider = getProvider(config.provider);
|
|
@@ -266,75 +213,14 @@ function buildClient(config) {
|
|
|
266
213
|
}
|
|
267
214
|
return new OpenAI(clientOptions);
|
|
268
215
|
}
|
|
269
|
-
// ─── Sub-components ───
|
|
270
|
-
/** Shows filtered slash commands when user types /. */
|
|
271
|
-
const CommandFilter = ({ inputText }) => {
|
|
272
|
-
if (!inputText.startsWith('/'))
|
|
273
|
-
return null;
|
|
274
|
-
const filtered = SLASH_COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(inputText.toLowerCase()));
|
|
275
|
-
if (filtered.length === 0)
|
|
276
|
-
return null;
|
|
277
|
-
return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: filtered.map((cmd) => (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "green", children: cmd.name }), " \u2014 ", cmd.description] }, cmd.name))) }));
|
|
278
|
-
};
|
|
279
|
-
/** Interactive approval prompt rendered inline. */
|
|
280
|
-
const ApprovalPrompt = ({ request, onRespond }) => {
|
|
281
|
-
const sessionApprovalLabel = request.sessionScopeKey
|
|
282
|
-
? 'Approve this operation for session'
|
|
283
|
-
: `Approve all "${request.type}" for session`;
|
|
284
|
-
const items = [
|
|
285
|
-
{ label: 'Approve once', value: 'approve_once' },
|
|
286
|
-
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
287
|
-
{ label: 'Reject', value: 'reject' },
|
|
288
|
-
];
|
|
289
|
-
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) }) })] }));
|
|
290
|
-
};
|
|
291
|
-
/** Cost/usage display in the status bar. */
|
|
292
|
-
const UsageDisplay = ({ usage, totalCost }) => {
|
|
293
|
-
if (!usage && totalCost === 0)
|
|
294
|
-
return null;
|
|
295
|
-
return (_jsxs(Box, { marginTop: 1, children: [usage && (_jsxs(Box, { children: [_jsxs(Box, { backgroundColor: "#064e3b", paddingX: 1, children: [_jsx(Text, { color: "white", children: "tokens: " }), _jsxs(Text, { color: "white", bold: true, children: [usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191"] })] }), _jsxs(Box, { backgroundColor: "#065f46", paddingX: 1, children: [_jsx(Text, { color: "white", children: "ctx: " }), _jsxs(Text, { color: "white", bold: true, children: [usage.contextPercent.toFixed(0), "%"] })] })] })), totalCost > 0 && (_jsxs(Box, { backgroundColor: "#064e3b", paddingX: 1, children: [_jsx(Text, { color: "black", children: "cost: " }), _jsxs(Text, { color: "black", bold: true, children: ["$", totalCost.toFixed(4)] })] }))] }));
|
|
296
|
-
};
|
|
297
|
-
/** Inline setup wizard — shown when no config exists. */
|
|
298
|
-
const InlineSetup = ({ onComplete }) => {
|
|
299
|
-
const [setupStep, setSetupStep] = useState('target');
|
|
300
|
-
const [target, setTarget] = useState('project');
|
|
301
|
-
const [selectedProviderId, setSelectedProviderId] = useState('');
|
|
302
|
-
const [selectedModelId, setSelectedModelId] = useState('');
|
|
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
|
-
}
|
|
319
|
-
if (setupStep === 'provider') {
|
|
320
|
-
return (_jsx(Box, { marginTop: 1, children: _jsx(ModelSelection, { setSelectedProviderId: setSelectedProviderId, setSelectedModelId: setSelectedModelId, onSelect: handleModelSelect, title: "First-time setup" }) }));
|
|
321
|
-
}
|
|
322
|
-
return (_jsx(Box, { marginTop: 1, children: _jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, target: target, title: "First-time setup", showProviderHeaders: false, onComplete: handleConfigComplete }) }));
|
|
323
|
-
};
|
|
324
216
|
// ─── Main App ───
|
|
325
217
|
export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }) => {
|
|
326
218
|
const { exit } = useApp();
|
|
327
219
|
const { stdout } = useStdout();
|
|
328
220
|
const terminalRows = stdout?.rows ?? 24;
|
|
329
221
|
// ─── Static scrollback state ───
|
|
330
|
-
// Each item
|
|
331
|
-
//
|
|
332
|
-
// Using <Static> items is important to avoid re-rendering issues, which hijack
|
|
333
|
-
// scrollback and copying when new AI message streams are coming in.
|
|
334
|
-
//
|
|
335
|
-
// staticCounterRef keeps ID generation local to this component instance,
|
|
336
|
-
// making it immune to Strict Mode double-invoke, HMR counter drift, and
|
|
337
|
-
// collisions if multiple App instances ever coexist.
|
|
222
|
+
// Each item is rendered once by <Static> and permanently flushed to scrollback.
|
|
223
|
+
// staticCounterRef generates unique IDs (s1, s2, s3...) for React keys.
|
|
338
224
|
const staticCounterRef = useRef(0);
|
|
339
225
|
const [staticItems, setStaticItems] = useState([]);
|
|
340
226
|
const addStatic = useCallback((node) => {
|
|
@@ -346,12 +232,6 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
346
232
|
const [config, setConfig] = useState(null);
|
|
347
233
|
const [completionMessages, setCompletionMessages] = useState([]);
|
|
348
234
|
const [inputText, setInputText] = useState('');
|
|
349
|
-
// isStreaming: true while the assistant is producing tokens.
|
|
350
|
-
// streamingText: the live in-progress token buffer shown in the dynamic Ink
|
|
351
|
-
// frame while the response streams. Cleared to '' at done and flushed to
|
|
352
|
-
// <Static> as a permanent scrollback item. Keeping it in React state (not a
|
|
353
|
-
// ref) is safe because the Ink frame height does NOT change as tokens arrive —
|
|
354
|
-
// the streaming box is always 1+ lines tall while loading=true.
|
|
355
235
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
356
236
|
const [streamingText, setStreamingText] = useState('');
|
|
357
237
|
const [loading, setLoading] = useState(false);
|
|
@@ -386,9 +266,22 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
386
266
|
unflushedContent: '',
|
|
387
267
|
hasFlushedAnyLine: false,
|
|
388
268
|
});
|
|
269
|
+
// Hook for handling agent events
|
|
270
|
+
const handleAgentEvent = useAgentEventHandler({
|
|
271
|
+
addStatic,
|
|
272
|
+
setCompletionMessages,
|
|
273
|
+
setIsStreaming,
|
|
274
|
+
setStreamingText,
|
|
275
|
+
setActiveTool,
|
|
276
|
+
setLastUsage,
|
|
277
|
+
setTotalCost,
|
|
278
|
+
setThreadErrors,
|
|
279
|
+
setError,
|
|
280
|
+
assistantMessageRef,
|
|
281
|
+
streamingBufferRef,
|
|
282
|
+
});
|
|
389
283
|
const didPrintIntroRef = useRef(false);
|
|
390
284
|
const printedThreadErrorIdsRef = useRef(new Set());
|
|
391
|
-
// ─── Post-config initialization (reused after inline setup) ───
|
|
392
285
|
const initializeWithConfig = useCallback(async (loadedConfig) => {
|
|
393
286
|
setConfig(loadedConfig);
|
|
394
287
|
clientRef.current = buildClient(loadedConfig);
|
|
@@ -432,7 +325,6 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
432
325
|
setNeedsSetup(false);
|
|
433
326
|
setInitialized(true);
|
|
434
327
|
}, [dangerouslySkipPermissions, sessionId, addStatic]);
|
|
435
|
-
// ─── Initialization ───
|
|
436
328
|
useEffect(() => {
|
|
437
329
|
if (!loading) {
|
|
438
330
|
setSpinnerFrame(0);
|
|
@@ -457,6 +349,7 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
457
349
|
addStatic(_jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }));
|
|
458
350
|
}
|
|
459
351
|
}, [threadErrors, addStatic]);
|
|
352
|
+
// One-time initialization: logging, approval handlers, config loading
|
|
460
353
|
useEffect(() => {
|
|
461
354
|
const init = async () => {
|
|
462
355
|
// Set log level and initialize log file
|
|
@@ -496,13 +389,13 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
496
389
|
closeMcp();
|
|
497
390
|
};
|
|
498
391
|
}, []);
|
|
499
|
-
// ─── Slash commands ───
|
|
500
392
|
const handleSlashCommand = useCallback(async (cmd) => {
|
|
501
393
|
const parts = cmd.trim().split(/\s+/);
|
|
502
394
|
const command = parts[0]?.toLowerCase();
|
|
503
395
|
switch (command) {
|
|
504
396
|
case '/quit':
|
|
505
397
|
case '/exit':
|
|
398
|
+
// No active session: exit immediately. Otherwise: save before exit.
|
|
506
399
|
if (!session) {
|
|
507
400
|
exit();
|
|
508
401
|
return true;
|
|
@@ -525,10 +418,6 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
525
418
|
setError(`Failed to save session before exit: ${err.message}`);
|
|
526
419
|
}
|
|
527
420
|
return true;
|
|
528
|
-
case '/expand':
|
|
529
|
-
case '/collapse':
|
|
530
|
-
// expand/collapse removed — transcript lives in scrollback
|
|
531
|
-
return true;
|
|
532
421
|
case '/help':
|
|
533
422
|
setHelpMessage(HELP_TEXT);
|
|
534
423
|
return true;
|
|
@@ -536,7 +425,6 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
536
425
|
return false;
|
|
537
426
|
}
|
|
538
427
|
}, [config, exit, session, completionMessages]);
|
|
539
|
-
// ─── Submit handler ───
|
|
540
428
|
const handleSubmit = useCallback(async (value) => {
|
|
541
429
|
const trimmed = value.trim();
|
|
542
430
|
if (!trimmed || loading || !clientRef.current || !config)
|
|
@@ -573,258 +461,7 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
573
461
|
const requestDefaults = getRequestDefaultParams(config.provider, config.model);
|
|
574
462
|
// Create abort controller for this completion
|
|
575
463
|
abortControllerRef.current = new AbortController();
|
|
576
|
-
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed,
|
|
577
|
-
switch (event.type) {
|
|
578
|
-
case 'text_delta': {
|
|
579
|
-
const deltaText = event.content || '';
|
|
580
|
-
// First text delta of this turn: initialize ref, show streaming indicator.
|
|
581
|
-
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
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: [] };
|
|
585
|
-
const idx = completionMessages.length + 1;
|
|
586
|
-
assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
|
|
587
|
-
setIsStreaming(true);
|
|
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);
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
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);
|
|
629
|
-
}
|
|
630
|
-
break;
|
|
631
|
-
}
|
|
632
|
-
case 'sub_agent_iteration':
|
|
633
|
-
if (event.subAgentTool) {
|
|
634
|
-
const { tool, status, args } = event.subAgentTool;
|
|
635
|
-
if (status === 'running') {
|
|
636
|
-
setActiveTool(formatSubAgentActivity(tool, args));
|
|
637
|
-
}
|
|
638
|
-
else {
|
|
639
|
-
setActiveTool(null);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
// Handle sub-agent usage update
|
|
643
|
-
if (event.subAgentUsage) {
|
|
644
|
-
setTotalCost((prev) => prev + event.subAgentUsage.cost);
|
|
645
|
-
}
|
|
646
|
-
break;
|
|
647
|
-
case 'tool_call':
|
|
648
|
-
if (event.toolCall) {
|
|
649
|
-
const toolCall = event.toolCall;
|
|
650
|
-
setActiveTool(toolCall.name);
|
|
651
|
-
// If the model streamed some text before invoking this tool,
|
|
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.
|
|
655
|
-
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
656
|
-
const buffer = streamingBufferRef.current;
|
|
657
|
-
// Flush any remaining unflushed content
|
|
658
|
-
if (buffer.unflushedContent) {
|
|
659
|
-
addStatic(renderFormattedText(buffer.unflushedContent));
|
|
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 };
|
|
665
|
-
setIsStreaming(false);
|
|
666
|
-
setStreamingText('');
|
|
667
|
-
assistantMessageRef.current = null;
|
|
668
|
-
}
|
|
669
|
-
// Track the tool call in the ref WITHOUT triggering a render.
|
|
670
|
-
// The render will happen when tool_result arrives.
|
|
671
|
-
const existingRef = assistantMessageRef.current;
|
|
672
|
-
const assistantMsg = existingRef?.message
|
|
673
|
-
? {
|
|
674
|
-
...existingRef.message,
|
|
675
|
-
tool_calls: [...(existingRef.message.tool_calls || [])],
|
|
676
|
-
}
|
|
677
|
-
: { role: 'assistant', content: '', tool_calls: [] };
|
|
678
|
-
const nextToolCall = {
|
|
679
|
-
id: toolCall.id,
|
|
680
|
-
type: 'function',
|
|
681
|
-
function: { name: toolCall.name, arguments: toolCall.args },
|
|
682
|
-
};
|
|
683
|
-
const idx = assistantMsg.tool_calls.findIndex((tc) => tc.id === toolCall.id);
|
|
684
|
-
if (idx === -1) {
|
|
685
|
-
assistantMsg.tool_calls.push(nextToolCall);
|
|
686
|
-
}
|
|
687
|
-
else {
|
|
688
|
-
assistantMsg.tool_calls[idx] = nextToolCall;
|
|
689
|
-
}
|
|
690
|
-
if (!existingRef) {
|
|
691
|
-
// First tool call — we need to add the assistant message to state
|
|
692
|
-
setCompletionMessages((prev) => {
|
|
693
|
-
assistantMessageRef.current = {
|
|
694
|
-
message: assistantMsg,
|
|
695
|
-
index: prev.length,
|
|
696
|
-
kind: 'tool_call_assistant',
|
|
697
|
-
};
|
|
698
|
-
return [...prev, assistantMsg];
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
else {
|
|
702
|
-
// Subsequent tool calls — just update the ref, no render
|
|
703
|
-
assistantMessageRef.current = {
|
|
704
|
-
...existingRef,
|
|
705
|
-
message: assistantMsg,
|
|
706
|
-
kind: 'tool_call_assistant',
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
break;
|
|
711
|
-
case 'tool_result':
|
|
712
|
-
if (event.toolCall) {
|
|
713
|
-
const toolCall = event.toolCall;
|
|
714
|
-
setActiveTool(null);
|
|
715
|
-
// Write the tool summary immediately — at this point loading is
|
|
716
|
-
// still true but the frame height is stable (spinner + input box).
|
|
717
|
-
// The next state change (setActiveTool(null)) doesn't affect
|
|
718
|
-
// frame height so write() restores the correct frame.
|
|
719
|
-
const compactResult = (toolCall.result || '')
|
|
720
|
-
.replace(/\s+/g, ' ')
|
|
721
|
-
.trim()
|
|
722
|
-
.slice(0, 180);
|
|
723
|
-
addStatic(_jsxs(Text, { dimColor: true, children: ['▶ ', toolCall.name, ': ', compactResult, '\n'] }));
|
|
724
|
-
// Flush the assistant message + tool result into completionMessages
|
|
725
|
-
// for session saving.
|
|
726
|
-
setCompletionMessages((prev) => {
|
|
727
|
-
const updated = [...prev];
|
|
728
|
-
// Sync assistant message
|
|
729
|
-
if (assistantMessageRef.current) {
|
|
730
|
-
updated[assistantMessageRef.current.index] = {
|
|
731
|
-
...assistantMessageRef.current.message,
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
// Append tool result
|
|
735
|
-
updated.push({
|
|
736
|
-
role: 'tool',
|
|
737
|
-
tool_call_id: toolCall.id,
|
|
738
|
-
content: toolCall.result || '',
|
|
739
|
-
name: toolCall.name,
|
|
740
|
-
});
|
|
741
|
-
return updated;
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
break;
|
|
745
|
-
case 'usage':
|
|
746
|
-
if (event.usage) {
|
|
747
|
-
setLastUsage(event.usage);
|
|
748
|
-
setTotalCost((prev) => prev + event.usage.cost);
|
|
749
|
-
}
|
|
750
|
-
break;
|
|
751
|
-
case 'error':
|
|
752
|
-
if (event.error) {
|
|
753
|
-
const errorMessage = event.error;
|
|
754
|
-
setThreadErrors((prev) => {
|
|
755
|
-
if (event.transient) {
|
|
756
|
-
return [
|
|
757
|
-
...prev.filter((threadError) => !threadError.transient),
|
|
758
|
-
{
|
|
759
|
-
id: `${Date.now()}-${prev.length}`,
|
|
760
|
-
message: errorMessage,
|
|
761
|
-
transient: true,
|
|
762
|
-
},
|
|
763
|
-
];
|
|
764
|
-
}
|
|
765
|
-
if (prev[prev.length - 1]?.message === errorMessage) {
|
|
766
|
-
return prev;
|
|
767
|
-
}
|
|
768
|
-
return [
|
|
769
|
-
...prev,
|
|
770
|
-
{
|
|
771
|
-
id: `${Date.now()}-${prev.length}`,
|
|
772
|
-
message: errorMessage,
|
|
773
|
-
transient: false,
|
|
774
|
-
},
|
|
775
|
-
];
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
else {
|
|
779
|
-
setError('Unknown error');
|
|
780
|
-
}
|
|
781
|
-
break;
|
|
782
|
-
case 'iteration_done':
|
|
783
|
-
if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
|
|
784
|
-
assistantMessageRef.current = null;
|
|
785
|
-
}
|
|
786
|
-
break;
|
|
787
|
-
case 'done':
|
|
788
|
-
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
789
|
-
const finalRef = assistantMessageRef.current;
|
|
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'));
|
|
811
|
-
}
|
|
812
|
-
// Clear streaming state and buffer
|
|
813
|
-
setIsStreaming(false);
|
|
814
|
-
setStreamingText('');
|
|
815
|
-
streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
|
|
816
|
-
setCompletionMessages((prev) => {
|
|
817
|
-
const updated = [...prev];
|
|
818
|
-
updated[finalRef.index] = { ...finalRef.message };
|
|
819
|
-
return updated;
|
|
820
|
-
});
|
|
821
|
-
assistantMessageRef.current = null;
|
|
822
|
-
}
|
|
823
|
-
setActiveTool(null);
|
|
824
|
-
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
825
|
-
break;
|
|
826
|
-
}
|
|
827
|
-
}, {
|
|
464
|
+
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, handleAgentEvent, {
|
|
828
465
|
pricing: pricing || undefined,
|
|
829
466
|
abortSignal: abortControllerRef.current.signal,
|
|
830
467
|
sessionId: session?.id,
|
|
@@ -847,7 +484,6 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
847
484
|
setLoading(false);
|
|
848
485
|
}
|
|
849
486
|
}, [loading, config, completionMessages, session, handleSlashCommand, addStatic]);
|
|
850
|
-
// ─── Keyboard shortcuts ───
|
|
851
487
|
useInput((input, key) => {
|
|
852
488
|
if (key.ctrl && input === 'c') {
|
|
853
489
|
exit();
|
|
@@ -857,7 +493,6 @@ export const App = ({ dangerouslySkipPermissions = false, logLevel, sessionId, }
|
|
|
857
493
|
abortControllerRef.current.abort();
|
|
858
494
|
}
|
|
859
495
|
});
|
|
860
|
-
// ─── Render ───
|
|
861
496
|
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) => {
|
|
862
497
|
initializeWithConfig(newConfig).catch((err) => {
|
|
863
498
|
setError(`Initialization failed: ${err.message}`);
|