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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Select } from '@inkjs/ui';
|
|
4
|
+
import { LeftBar } from './LeftBar.js';
|
|
5
|
+
/**
|
|
6
|
+
* Interactive approval prompt rendered inline.
|
|
7
|
+
*/
|
|
8
|
+
export const ApprovalPrompt = ({ request, onRespond }) => {
|
|
9
|
+
const sessionApprovalLabel = request.sessionScopeKey
|
|
10
|
+
? 'Approve this operation for session'
|
|
11
|
+
: `Approve all "${request.type}" for session`;
|
|
12
|
+
const items = [
|
|
13
|
+
{ label: 'Approve once', value: 'approve_once' },
|
|
14
|
+
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
15
|
+
{ label: 'Reject', value: 'reject' },
|
|
16
|
+
];
|
|
17
|
+
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) }) })] }));
|
|
18
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
// ─── Available slash commands ───
|
|
4
|
+
export const SLASH_COMMANDS = [
|
|
5
|
+
{ name: '/help', description: 'Show all available commands' },
|
|
6
|
+
{ name: '/quit', description: 'Exit ProtoAgent' },
|
|
7
|
+
{ name: '/exit', description: 'Alias for /quit' },
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Shows filtered slash commands when user types /.
|
|
11
|
+
*/
|
|
12
|
+
export const CommandFilter = ({ inputText }) => {
|
|
13
|
+
if (!inputText.startsWith('/'))
|
|
14
|
+
return null;
|
|
15
|
+
const filtered = SLASH_COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(inputText.toLowerCase()));
|
|
16
|
+
if (filtered.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
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))) }));
|
|
19
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box } from 'ink';
|
|
4
|
+
import { writeConfig, writeInitConfig, TargetSelection, ModelSelection, ApiKeyInput, } from '../config.js';
|
|
5
|
+
/**
|
|
6
|
+
* Inline setup wizard — shown when no config exists.
|
|
7
|
+
*/
|
|
8
|
+
export const InlineSetup = ({ onComplete }) => {
|
|
9
|
+
const [setupStep, setSetupStep] = useState('target');
|
|
10
|
+
const [target, setTarget] = useState('project');
|
|
11
|
+
const [selectedProviderId, setSelectedProviderId] = useState('');
|
|
12
|
+
const [selectedModelId, setSelectedModelId] = useState('');
|
|
13
|
+
const handleModelSelect = (providerId, modelId) => {
|
|
14
|
+
setSelectedProviderId(providerId);
|
|
15
|
+
setSelectedModelId(modelId);
|
|
16
|
+
setSetupStep('api_key');
|
|
17
|
+
};
|
|
18
|
+
const handleConfigComplete = (config) => {
|
|
19
|
+
writeInitConfig(target);
|
|
20
|
+
writeConfig(config, target);
|
|
21
|
+
onComplete(config);
|
|
22
|
+
};
|
|
23
|
+
if (setupStep === 'target') {
|
|
24
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(TargetSelection, { title: "First-time setup", subtitle: "Create a ProtoAgent runtime config:", onSelect: (value) => {
|
|
25
|
+
setTarget(value);
|
|
26
|
+
setSetupStep('provider');
|
|
27
|
+
} }) }));
|
|
28
|
+
}
|
|
29
|
+
if (setupStep === 'provider') {
|
|
30
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(ModelSelection, { setSelectedProviderId: setSelectedProviderId, setSelectedModelId: setSelectedModelId, onSelect: handleModelSelect, title: "First-time setup" }) }));
|
|
31
|
+
}
|
|
32
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, target: target, title: "First-time setup", showProviderHeaders: false, onComplete: handleConfigComplete }) }));
|
|
33
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Cost/usage display in the status bar.
|
|
5
|
+
*/
|
|
6
|
+
export const UsageDisplay = ({ usage, totalCost }) => {
|
|
7
|
+
if (!usage && totalCost === 0)
|
|
8
|
+
return null;
|
|
9
|
+
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)] })] }))] }));
|
|
10
|
+
};
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,7 @@ import { useState } from 'react';
|
|
|
6
6
|
import { Box, Text } from 'ink';
|
|
7
7
|
import { Select, TextInput, PasswordInput } from '@inkjs/ui';
|
|
8
8
|
import { parse } from 'jsonc-parser';
|
|
9
|
-
import { getActiveRuntimeConfigPath } from './runtime-config.js';
|
|
9
|
+
import { getActiveRuntimeConfigPath, RuntimeConfigFileSchema } from './runtime-config.js';
|
|
10
10
|
import { getAllProviders, getProvider } from './providers.js';
|
|
11
11
|
const CONFIG_DIR_MODE = 0o700;
|
|
12
12
|
const CONFIG_FILE_MODE = 0o600;
|
|
@@ -76,10 +76,10 @@ export const getProjectRuntimeConfigDirectory = (cwd = process.cwd()) => {
|
|
|
76
76
|
export const getProjectRuntimeConfigPath = (cwd = process.cwd()) => {
|
|
77
77
|
return path.join(getProjectRuntimeConfigDirectory(cwd), 'protoagent.jsonc');
|
|
78
78
|
};
|
|
79
|
-
export const
|
|
79
|
+
export const getRuntimeConfigPath = (target, cwd = process.cwd()) => {
|
|
80
80
|
return target === 'project' ? getProjectRuntimeConfigPath(cwd) : getUserRuntimeConfigPath();
|
|
81
81
|
};
|
|
82
|
-
const RUNTIME_CONFIG_TEMPLATE = `{
|
|
82
|
+
export const RUNTIME_CONFIG_TEMPLATE = `{
|
|
83
83
|
// Add project or user-wide ProtoAgent runtime config here.
|
|
84
84
|
// Example uses:
|
|
85
85
|
// - choose the active provider/model by making it the first provider
|
|
@@ -151,13 +151,22 @@ function readRuntimeConfigFileSync(configPath) {
|
|
|
151
151
|
if (errors.length > 0 || !isPlainObject(parsed)) {
|
|
152
152
|
return null;
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// Validate against zod schema
|
|
155
|
+
const result = RuntimeConfigFileSchema.safeParse(parsed);
|
|
156
|
+
if (!result.success) {
|
|
157
|
+
console.error('Invalid runtime config format:', result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', '));
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return result.data;
|
|
155
161
|
}
|
|
156
162
|
catch (error) {
|
|
157
163
|
console.error('Error reading runtime config file:', error);
|
|
158
164
|
return null;
|
|
159
165
|
}
|
|
160
166
|
}
|
|
167
|
+
// Returns the first provider with a valid model from the runtime config.
|
|
168
|
+
// The active provider/model is determined by order: first provider in the
|
|
169
|
+
// config with at least one model, and the first model under that provider.
|
|
161
170
|
function getConfiguredProviderAndModel(runtimeConfig) {
|
|
162
171
|
for (const [providerId, providerConfig] of Object.entries(runtimeConfig.providers || {})) {
|
|
163
172
|
const modelId = Object.keys(providerConfig.models || {})[0];
|
|
@@ -179,32 +188,36 @@ function writeRuntimeConfigFile(configPath, runtimeConfig) {
|
|
|
179
188
|
writeFileSync(configPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, { encoding: 'utf8', mode: CONFIG_FILE_MODE });
|
|
180
189
|
hardenPermissions(configPath, CONFIG_FILE_MODE);
|
|
181
190
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
191
|
+
// Merges a new provider/model configuration into the existing runtime config.
|
|
192
|
+
// Used by writeConfig() to add/update a provider without losing existing data.
|
|
193
|
+
// - newConfig: the provider/model to add (from user running 'configure' command)
|
|
194
|
+
// - existingRuntimeConfig: the current runtime config file content (may be empty)
|
|
195
|
+
function upsertSelectedConfig(existingRuntimeConfig, newConfig) {
|
|
196
|
+
const existingProviders = existingRuntimeConfig.providers || {};
|
|
197
|
+
const currentProvider = existingProviders[newConfig.provider] || {};
|
|
185
198
|
const currentModels = currentProvider.models || {};
|
|
186
|
-
const selectedModelConfig = currentModels[
|
|
199
|
+
const selectedModelConfig = currentModels[newConfig.model] || {};
|
|
187
200
|
const nextProvider = {
|
|
188
201
|
...currentProvider,
|
|
189
|
-
...(
|
|
202
|
+
...(newConfig.apiKey?.trim() ? { apiKey: newConfig.apiKey.trim() } : {}),
|
|
190
203
|
models: Object.fromEntries([
|
|
191
|
-
[
|
|
192
|
-
...Object.entries(currentModels).filter(([modelId]) => modelId !==
|
|
204
|
+
[newConfig.model, selectedModelConfig],
|
|
205
|
+
...Object.entries(currentModels).filter(([modelId]) => modelId !== newConfig.model),
|
|
193
206
|
]),
|
|
194
207
|
};
|
|
195
|
-
if (!
|
|
208
|
+
if (!newConfig.apiKey?.trim()) {
|
|
196
209
|
delete nextProvider.apiKey;
|
|
197
210
|
}
|
|
198
211
|
return {
|
|
199
|
-
...
|
|
212
|
+
...existingRuntimeConfig,
|
|
200
213
|
providers: Object.fromEntries([
|
|
201
|
-
[
|
|
202
|
-
...Object.entries(existingProviders).filter(([providerId]) => providerId !==
|
|
214
|
+
[newConfig.provider, nextProvider],
|
|
215
|
+
...Object.entries(existingProviders).filter(([providerId]) => providerId !== newConfig.provider),
|
|
203
216
|
]),
|
|
204
217
|
};
|
|
205
218
|
}
|
|
206
219
|
export function writeInitConfig(target, cwd = process.cwd(), options = {}) {
|
|
207
|
-
const configPath =
|
|
220
|
+
const configPath = getRuntimeConfigPath(target, cwd);
|
|
208
221
|
const alreadyExists = existsSync(configPath);
|
|
209
222
|
if (alreadyExists) {
|
|
210
223
|
if (!options.overwrite) {
|
|
@@ -218,8 +231,13 @@ export function writeInitConfig(target, cwd = process.cwd(), options = {}) {
|
|
|
218
231
|
hardenPermissions(configPath, CONFIG_FILE_MODE);
|
|
219
232
|
return { path: configPath, status: alreadyExists ? 'overwritten' : 'created' };
|
|
220
233
|
}
|
|
234
|
+
// Reads the provider/model config from a runtime config file.
|
|
235
|
+
// - 'project': read from <cwd>/.protoagent/protoagent.jsonc
|
|
236
|
+
// - 'user': read from ~/.config/protoagent/protoagent.jsonc
|
|
237
|
+
// - 'active' (default): check project config first, fall back to user config
|
|
238
|
+
// This is what the agent uses at runtime to determine which provider/model to use.
|
|
221
239
|
export const readConfig = (target = 'active', cwd = process.cwd()) => {
|
|
222
|
-
const configPath = target === 'active' ? getActiveRuntimeConfigPath() :
|
|
240
|
+
const configPath = target === 'active' ? getActiveRuntimeConfigPath() : getRuntimeConfigPath(target, cwd);
|
|
223
241
|
if (!configPath) {
|
|
224
242
|
return null;
|
|
225
243
|
}
|
|
@@ -229,22 +247,14 @@ export const readConfig = (target = 'active', cwd = process.cwd()) => {
|
|
|
229
247
|
}
|
|
230
248
|
return getConfiguredProviderAndModel(runtimeConfig);
|
|
231
249
|
};
|
|
232
|
-
export function getDefaultConfigTarget(cwd = process.cwd()) {
|
|
233
|
-
const activeConfigPath = getActiveRuntimeConfigPath();
|
|
234
|
-
if (activeConfigPath === getProjectRuntimeConfigPath(cwd)) {
|
|
235
|
-
return 'project';
|
|
236
|
-
}
|
|
237
|
-
return 'user';
|
|
238
|
-
}
|
|
239
250
|
export const writeConfig = (config, target = 'user', cwd = process.cwd()) => {
|
|
240
|
-
const configPath =
|
|
251
|
+
const configPath = getRuntimeConfigPath(target, cwd);
|
|
241
252
|
const runtimeConfig = readRuntimeConfigFileSync(configPath) || { providers: {}, mcp: { servers: {} } };
|
|
242
253
|
const nextRuntimeConfig = upsertSelectedConfig(runtimeConfig, config);
|
|
243
254
|
writeRuntimeConfigFile(configPath, nextRuntimeConfig);
|
|
244
255
|
return configPath;
|
|
245
256
|
};
|
|
246
257
|
export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
|
|
247
|
-
const [resetInput, setResetInput] = useState('');
|
|
248
258
|
const provider = getProvider(existingConfig.provider);
|
|
249
259
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Existing configuration found:" }), _jsxs(Text, { children: [" Provider: ", provider?.name || existingConfig.provider] }), _jsxs(Text, { children: [" Model: ", existingConfig.model] }), _jsxs(Text, { children: [" API Key: ", '*'.repeat(8)] }), _jsx(Text, { children: " " }), _jsx(Text, { children: "Do you want to reset and configure a new one? (y/n)" }), _jsx(TextInput, { onSubmit: (answer) => {
|
|
250
260
|
if (answer.toLowerCase() === 'y') {
|
|
@@ -310,7 +320,7 @@ export const ConfigResult = ({ configWritten }) => {
|
|
|
310
320
|
};
|
|
311
321
|
export const ConfigureComponent = () => {
|
|
312
322
|
const [step, setStep] = useState(0);
|
|
313
|
-
const [target, setTarget] = useState(
|
|
323
|
+
const [target, setTarget] = useState('user');
|
|
314
324
|
const [existingConfig, setExistingConfig] = useState(null);
|
|
315
325
|
const [selectedProviderId, setSelectedProviderId] = useState('');
|
|
316
326
|
const [selectedModelId, setSelectedModelId] = useState('');
|
|
@@ -339,22 +349,20 @@ export const ConfigureComponent = () => {
|
|
|
339
349
|
export const InitComponent = () => {
|
|
340
350
|
const [selectedTarget, setSelectedTarget] = useState(null);
|
|
341
351
|
const [result, setResult] = useState(null);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const activeTarget = selectedTarget ?? 'project';
|
|
355
|
-
const activeOption = options.find((option) => option.value === activeTarget) ?? options[0];
|
|
352
|
+
// Step 1: Show target selection
|
|
353
|
+
if (!selectedTarget && !result) {
|
|
354
|
+
return (_jsx(TargetSelection, { title: "Create a ProtoAgent runtime config", subtitle: "Select where to write `protoagent.jsonc`", onSelect: (target) => {
|
|
355
|
+
const configPath = getRuntimeConfigPath(target);
|
|
356
|
+
if (existsSync(configPath)) {
|
|
357
|
+
setSelectedTarget(target);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
setResult(writeInitConfig(target));
|
|
361
|
+
} }));
|
|
362
|
+
}
|
|
363
|
+
// Step 2: Target selected but file exists - confirm overwrite
|
|
356
364
|
if (selectedTarget && !result) {
|
|
357
|
-
const selectedPath =
|
|
365
|
+
const selectedPath = getRuntimeConfigPath(selectedTarget);
|
|
358
366
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Config already exists:" }), _jsx(Text, { children: selectedPath }), _jsx(Text, { children: "Overwrite it? (y/n)" }), _jsx(TextInput, { onSubmit: (answer) => {
|
|
359
367
|
if (answer.trim().toLowerCase() === 'y') {
|
|
360
368
|
setResult(writeInitConfig(selectedTarget, process.cwd(), { overwrite: true }));
|
|
@@ -364,6 +372,7 @@ export const InitComponent = () => {
|
|
|
364
372
|
}
|
|
365
373
|
} })] }));
|
|
366
374
|
}
|
|
375
|
+
// Step 3: Show result
|
|
367
376
|
if (result) {
|
|
368
377
|
const color = result.status === 'exists' ? 'yellow' : 'green';
|
|
369
378
|
const message = result.status === 'created'
|
|
@@ -373,13 +382,5 @@ export const InitComponent = () => {
|
|
|
373
382
|
: 'ProtoAgent config already exists:';
|
|
374
383
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: color, children: message }), _jsx(Text, { children: result.path })] }));
|
|
375
384
|
}
|
|
376
|
-
return
|
|
377
|
-
const target = value;
|
|
378
|
-
const configPath = getInitConfigPath(target);
|
|
379
|
-
if (existsSync(configPath)) {
|
|
380
|
-
setSelectedTarget(target);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
setResult(writeInitConfig(target));
|
|
384
|
-
} }) })] }));
|
|
385
|
+
return null;
|
|
385
386
|
};
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { Text } from 'ink';
|
|
4
|
+
import { renderFormattedText, normalizeTranscriptText } from '../utils/format-message.js';
|
|
5
|
+
import { formatSubAgentActivity, formatToolActivity } from '../utils/tool-display.js';
|
|
6
|
+
export function useAgentEventHandler(options) {
|
|
7
|
+
const { addStatic, setCompletionMessages, setIsStreaming, setStreamingText, setActiveTool, setLastUsage, setTotalCost, setThreadErrors, setError, assistantMessageRef, streamingBufferRef, } = options;
|
|
8
|
+
return useCallback((event) => {
|
|
9
|
+
switch (event.type) {
|
|
10
|
+
case 'text_delta': {
|
|
11
|
+
handleTextDelta(event, {
|
|
12
|
+
addStatic,
|
|
13
|
+
setCompletionMessages,
|
|
14
|
+
setIsStreaming,
|
|
15
|
+
setStreamingText,
|
|
16
|
+
assistantMessageRef,
|
|
17
|
+
streamingBufferRef,
|
|
18
|
+
});
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
case 'sub_agent_iteration': {
|
|
22
|
+
handleSubAgentIteration(event, {
|
|
23
|
+
setActiveTool,
|
|
24
|
+
setTotalCost,
|
|
25
|
+
});
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
case 'tool_call': {
|
|
29
|
+
handleToolCall(event, {
|
|
30
|
+
addStatic,
|
|
31
|
+
setCompletionMessages,
|
|
32
|
+
setActiveTool,
|
|
33
|
+
assistantMessageRef,
|
|
34
|
+
streamingBufferRef,
|
|
35
|
+
setIsStreaming,
|
|
36
|
+
setStreamingText,
|
|
37
|
+
});
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case 'tool_result': {
|
|
41
|
+
handleToolResult(event, {
|
|
42
|
+
addStatic,
|
|
43
|
+
setCompletionMessages,
|
|
44
|
+
setActiveTool,
|
|
45
|
+
assistantMessageRef,
|
|
46
|
+
});
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'usage': {
|
|
50
|
+
handleUsage(event, { setLastUsage, setTotalCost });
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'error': {
|
|
54
|
+
handleError(event, { setThreadErrors, setError });
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'iteration_done': {
|
|
58
|
+
handleIterationDone({ assistantMessageRef });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'done': {
|
|
62
|
+
handleDone(event, {
|
|
63
|
+
addStatic,
|
|
64
|
+
setCompletionMessages,
|
|
65
|
+
setIsStreaming,
|
|
66
|
+
setStreamingText,
|
|
67
|
+
setActiveTool,
|
|
68
|
+
setThreadErrors,
|
|
69
|
+
assistantMessageRef,
|
|
70
|
+
streamingBufferRef,
|
|
71
|
+
});
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, [
|
|
76
|
+
addStatic,
|
|
77
|
+
setCompletionMessages,
|
|
78
|
+
setIsStreaming,
|
|
79
|
+
setStreamingText,
|
|
80
|
+
setActiveTool,
|
|
81
|
+
setLastUsage,
|
|
82
|
+
setTotalCost,
|
|
83
|
+
setThreadErrors,
|
|
84
|
+
setError,
|
|
85
|
+
assistantMessageRef,
|
|
86
|
+
streamingBufferRef,
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
// Helper to flush streaming buffer to static and reset state
|
|
90
|
+
function flushStreamingBuffer(ctx) {
|
|
91
|
+
const { addStatic, setIsStreaming, setStreamingText, streamingBufferRef } = ctx;
|
|
92
|
+
const buffer = streamingBufferRef.current;
|
|
93
|
+
if (buffer.unflushedContent) {
|
|
94
|
+
addStatic(renderFormattedText(buffer.unflushedContent));
|
|
95
|
+
}
|
|
96
|
+
streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
|
|
97
|
+
setIsStreaming(false);
|
|
98
|
+
setStreamingText('');
|
|
99
|
+
}
|
|
100
|
+
function handleTextDelta(event, ctx) {
|
|
101
|
+
const deltaText = event.content || '';
|
|
102
|
+
const { assistantMessageRef, streamingBufferRef, addStatic, setCompletionMessages, setIsStreaming, setStreamingText } = ctx;
|
|
103
|
+
// First text delta of this turn: initialize ref, show streaming indicator.
|
|
104
|
+
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
105
|
+
// Trim leading whitespace from first delta - LLMs often output leading \n or spaces
|
|
106
|
+
const trimmedDelta = deltaText.replace(/^\s+/, '');
|
|
107
|
+
const assistantMsg = { role: 'assistant', content: trimmedDelta, tool_calls: [] };
|
|
108
|
+
// Use functional update to get correct index
|
|
109
|
+
setCompletionMessages((prev) => {
|
|
110
|
+
const idx = prev.length;
|
|
111
|
+
assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
|
|
112
|
+
return [...prev, assistantMsg];
|
|
113
|
+
});
|
|
114
|
+
setIsStreaming(true);
|
|
115
|
+
// Initialize the streaming buffer and process the first chunk
|
|
116
|
+
const buffer = { unflushedContent: trimmedDelta, hasFlushedAnyLine: false };
|
|
117
|
+
streamingBufferRef.current = buffer;
|
|
118
|
+
// Process the first chunk: split on newlines and flush complete lines
|
|
119
|
+
const lines = buffer.unflushedContent.split('\n');
|
|
120
|
+
if (lines.length > 1) {
|
|
121
|
+
const completeLines = lines.slice(0, -1);
|
|
122
|
+
const textToFlush = completeLines.join('\n');
|
|
123
|
+
if (textToFlush) {
|
|
124
|
+
addStatic(renderFormattedText(textToFlush));
|
|
125
|
+
buffer.hasFlushedAnyLine = true;
|
|
126
|
+
}
|
|
127
|
+
buffer.unflushedContent = lines[lines.length - 1];
|
|
128
|
+
}
|
|
129
|
+
setStreamingText(buffer.unflushedContent);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Subsequent deltas — append to ref and buffer, then flush complete lines
|
|
133
|
+
assistantMessageRef.current.message.content += deltaText;
|
|
134
|
+
// Accumulate in buffer and flush complete lines to static
|
|
135
|
+
const buffer = streamingBufferRef.current;
|
|
136
|
+
buffer.unflushedContent += deltaText;
|
|
137
|
+
// Split on newlines to find complete lines
|
|
138
|
+
const lines = buffer.unflushedContent.split('\n');
|
|
139
|
+
// If we have more than 1 element, there were newlines
|
|
140
|
+
if (lines.length > 1) {
|
|
141
|
+
// All lines except the last one are complete (ended with \n)
|
|
142
|
+
const completeLines = lines.slice(0, -1);
|
|
143
|
+
// Build the text to flush - each complete line gets a newline added back
|
|
144
|
+
const textToFlush = completeLines.join('\n');
|
|
145
|
+
if (textToFlush) {
|
|
146
|
+
addStatic(renderFormattedText(textToFlush));
|
|
147
|
+
buffer.hasFlushedAnyLine = true;
|
|
148
|
+
}
|
|
149
|
+
// Keep only the last (incomplete) line in the buffer
|
|
150
|
+
buffer.unflushedContent = lines[lines.length - 1];
|
|
151
|
+
}
|
|
152
|
+
// Show the incomplete line (if any) in the dynamic frame
|
|
153
|
+
setStreamingText(buffer.unflushedContent);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function handleSubAgentIteration(event, ctx) {
|
|
157
|
+
const { setActiveTool, setTotalCost } = ctx;
|
|
158
|
+
if (event.subAgentTool) {
|
|
159
|
+
const { tool, status, args } = event.subAgentTool;
|
|
160
|
+
if (status === 'running') {
|
|
161
|
+
setActiveTool(formatSubAgentActivity(tool, args));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
setActiveTool(null);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Handle sub-agent usage update
|
|
168
|
+
if (event.subAgentUsage) {
|
|
169
|
+
setTotalCost((prev) => prev + event.subAgentUsage.estimatedCost);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function handleToolCall(event, ctx) {
|
|
173
|
+
const { setCompletionMessages, setActiveTool, assistantMessageRef } = ctx;
|
|
174
|
+
if (!event.toolCall)
|
|
175
|
+
return;
|
|
176
|
+
const toolCall = event.toolCall;
|
|
177
|
+
setActiveTool(toolCall.name);
|
|
178
|
+
// If the model streamed some text before invoking this tool,
|
|
179
|
+
// flush any remaining unflushed content to <Static> now.
|
|
180
|
+
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
181
|
+
// Flush buffer and add spacing before the tool call
|
|
182
|
+
flushStreamingBuffer(ctx);
|
|
183
|
+
ctx.addStatic(renderFormattedText('\n'));
|
|
184
|
+
assistantMessageRef.current = null;
|
|
185
|
+
}
|
|
186
|
+
// Track the tool call in the ref WITHOUT triggering a render.
|
|
187
|
+
// The render will happen when tool_result arrives.
|
|
188
|
+
const existingRef = assistantMessageRef.current;
|
|
189
|
+
const assistantMsg = existingRef?.message
|
|
190
|
+
? {
|
|
191
|
+
...existingRef.message,
|
|
192
|
+
tool_calls: [...(existingRef.message.tool_calls || [])],
|
|
193
|
+
}
|
|
194
|
+
: { role: 'assistant', content: '', tool_calls: [] };
|
|
195
|
+
const nextToolCall = {
|
|
196
|
+
id: toolCall.id,
|
|
197
|
+
type: 'function',
|
|
198
|
+
function: { name: toolCall.name, arguments: toolCall.args },
|
|
199
|
+
};
|
|
200
|
+
const idx = assistantMsg.tool_calls.findIndex((tc) => tc.id === toolCall.id);
|
|
201
|
+
if (idx === -1) {
|
|
202
|
+
assistantMsg.tool_calls.push(nextToolCall);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
assistantMsg.tool_calls[idx] = nextToolCall;
|
|
206
|
+
}
|
|
207
|
+
if (!existingRef) {
|
|
208
|
+
// First tool call — we need to add the assistant message to state
|
|
209
|
+
setCompletionMessages((prev) => {
|
|
210
|
+
assistantMessageRef.current = {
|
|
211
|
+
message: assistantMsg,
|
|
212
|
+
index: prev.length,
|
|
213
|
+
kind: 'tool_call_assistant',
|
|
214
|
+
};
|
|
215
|
+
return [...prev, assistantMsg];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Subsequent tool calls — just update the ref, no render
|
|
220
|
+
assistantMessageRef.current = {
|
|
221
|
+
...existingRef,
|
|
222
|
+
message: assistantMsg,
|
|
223
|
+
kind: 'tool_call_assistant',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function handleToolResult(event, ctx) {
|
|
228
|
+
const { addStatic, setCompletionMessages, setActiveTool, assistantMessageRef } = ctx;
|
|
229
|
+
if (!event.toolCall)
|
|
230
|
+
return;
|
|
231
|
+
const toolCall = event.toolCall;
|
|
232
|
+
setActiveTool(null);
|
|
233
|
+
// Write the tool summary immediately — at this point loading is
|
|
234
|
+
// still true but the frame height is stable (spinner + input box).
|
|
235
|
+
// The next state change (setActiveTool(null)) doesn't affect
|
|
236
|
+
// frame height so write() restores the correct frame.
|
|
237
|
+
const compactResult = (toolCall.result || '')
|
|
238
|
+
.replace(/\s+/g, ' ')
|
|
239
|
+
.trim()
|
|
240
|
+
.slice(0, 180);
|
|
241
|
+
// Parse tool args to show relevant parameter
|
|
242
|
+
let toolDisplay = toolCall.name;
|
|
243
|
+
try {
|
|
244
|
+
const args = JSON.parse(toolCall.args || '{}');
|
|
245
|
+
toolDisplay = formatToolActivity(toolCall.name, args);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// If parsing fails, just use the tool name
|
|
249
|
+
}
|
|
250
|
+
addStatic(_jsxs(Text, { dimColor: true, children: ['▶ ', toolDisplay, ': ', compactResult, '\n'] }));
|
|
251
|
+
// Flush the assistant message + tool result into completionMessages
|
|
252
|
+
// for session saving.
|
|
253
|
+
setCompletionMessages((prev) => {
|
|
254
|
+
const updated = [...prev];
|
|
255
|
+
// Sync assistant message
|
|
256
|
+
if (assistantMessageRef.current) {
|
|
257
|
+
updated[assistantMessageRef.current.index] = {
|
|
258
|
+
...assistantMessageRef.current.message,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// Append tool result with args for replay
|
|
262
|
+
updated.push({
|
|
263
|
+
role: 'tool',
|
|
264
|
+
tool_call_id: toolCall.id,
|
|
265
|
+
content: toolCall.result || '',
|
|
266
|
+
name: toolCall.name,
|
|
267
|
+
args: toolCall.args,
|
|
268
|
+
});
|
|
269
|
+
return updated;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function handleUsage(event, ctx) {
|
|
273
|
+
const { setLastUsage, setTotalCost } = ctx;
|
|
274
|
+
if (event.usage) {
|
|
275
|
+
setLastUsage(event.usage);
|
|
276
|
+
setTotalCost((prev) => prev + event.usage.cost);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function handleError(event, ctx) {
|
|
280
|
+
const { setThreadErrors, setError } = ctx;
|
|
281
|
+
if (event.error) {
|
|
282
|
+
const errorMessage = event.error;
|
|
283
|
+
setThreadErrors((prev) => {
|
|
284
|
+
if (event.transient) {
|
|
285
|
+
return [
|
|
286
|
+
...prev.filter((threadError) => !threadError.transient),
|
|
287
|
+
{
|
|
288
|
+
id: `${Date.now()}-${prev.length}`,
|
|
289
|
+
message: errorMessage,
|
|
290
|
+
transient: true,
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
if (prev[prev.length - 1]?.message === errorMessage) {
|
|
295
|
+
return prev;
|
|
296
|
+
}
|
|
297
|
+
return [
|
|
298
|
+
...prev,
|
|
299
|
+
{
|
|
300
|
+
id: `${Date.now()}-${prev.length}`,
|
|
301
|
+
message: errorMessage,
|
|
302
|
+
transient: false,
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
setError('Unknown error');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function handleIterationDone(ctx) {
|
|
312
|
+
const { assistantMessageRef } = ctx;
|
|
313
|
+
if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
|
|
314
|
+
assistantMessageRef.current = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function handleDone(_event, ctx) {
|
|
318
|
+
const { setCompletionMessages, setActiveTool, setThreadErrors, assistantMessageRef, streamingBufferRef } = ctx;
|
|
319
|
+
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
320
|
+
const finalRef = assistantMessageRef.current;
|
|
321
|
+
const buffer = streamingBufferRef.current;
|
|
322
|
+
// Flush any remaining unflushed content from the buffer
|
|
323
|
+
// This is the final incomplete line that was being displayed live
|
|
324
|
+
if (buffer.unflushedContent) {
|
|
325
|
+
// If we've already flushed some lines, just append the remainder
|
|
326
|
+
// Otherwise, normalize and flush the full content
|
|
327
|
+
if (buffer.hasFlushedAnyLine) {
|
|
328
|
+
ctx.addStatic(renderFormattedText(buffer.unflushedContent));
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
// Nothing was flushed yet, normalize the full content
|
|
332
|
+
const normalized = normalizeTranscriptText(finalRef.message.content || '');
|
|
333
|
+
if (normalized) {
|
|
334
|
+
ctx.addStatic(renderFormattedText(normalized));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Add final spacing after the streamed text
|
|
339
|
+
// Always add one newline - the user message adds another for blank line separation
|
|
340
|
+
if (buffer.unflushedContent) {
|
|
341
|
+
ctx.addStatic(renderFormattedText('\n'));
|
|
342
|
+
}
|
|
343
|
+
// Clear streaming state and buffer
|
|
344
|
+
ctx.setIsStreaming(false);
|
|
345
|
+
ctx.setStreamingText('');
|
|
346
|
+
streamingBufferRef.current = { unflushedContent: '', hasFlushedAnyLine: false };
|
|
347
|
+
setCompletionMessages((prev) => {
|
|
348
|
+
const updated = [...prev];
|
|
349
|
+
updated[finalRef.index] = { ...finalRef.message };
|
|
350
|
+
return updated;
|
|
351
|
+
});
|
|
352
|
+
assistantMessageRef.current = null;
|
|
353
|
+
}
|
|
354
|
+
setActiveTool(null);
|
|
355
|
+
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
356
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -104,6 +104,9 @@ async function registerMcpTools(conn) {
|
|
|
104
104
|
},
|
|
105
105
|
});
|
|
106
106
|
registerDynamicHandler(toolName, async (args) => {
|
|
107
|
+
// Note: Errors from this handler are caught and formatted by
|
|
108
|
+
// handleToolCall() in tools/index.ts, which wraps all tool calls
|
|
109
|
+
// in a try/catch and returns `Error executing ${toolName}: ${msg}`
|
|
107
110
|
const result = await conn.client.callTool({
|
|
108
111
|
name: tool.name,
|
|
109
112
|
arguments: (args && typeof args === 'object' ? args : {}),
|