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.
@@ -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 getInitConfigPath = (target, cwd = process.cwd()) => {
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
- return parsed;
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
- function upsertSelectedConfig(runtimeConfig, config) {
183
- const existingProviders = runtimeConfig.providers || {};
184
- const currentProvider = existingProviders[config.provider] || {};
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[config.model] || {};
199
+ const selectedModelConfig = currentModels[newConfig.model] || {};
187
200
  const nextProvider = {
188
201
  ...currentProvider,
189
- ...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
202
+ ...(newConfig.apiKey?.trim() ? { apiKey: newConfig.apiKey.trim() } : {}),
190
203
  models: Object.fromEntries([
191
- [config.model, selectedModelConfig],
192
- ...Object.entries(currentModels).filter(([modelId]) => modelId !== config.model),
204
+ [newConfig.model, selectedModelConfig],
205
+ ...Object.entries(currentModels).filter(([modelId]) => modelId !== newConfig.model),
193
206
  ]),
194
207
  };
195
- if (!config.apiKey?.trim()) {
208
+ if (!newConfig.apiKey?.trim()) {
196
209
  delete nextProvider.apiKey;
197
210
  }
198
211
  return {
199
- ...runtimeConfig,
212
+ ...existingRuntimeConfig,
200
213
  providers: Object.fromEntries([
201
- [config.provider, nextProvider],
202
- ...Object.entries(existingProviders).filter(([providerId]) => providerId !== config.provider),
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 = getInitConfigPath(target, cwd);
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() : getInitConfigPath(target, cwd);
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 = getInitConfigPath(target, cwd);
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(getDefaultConfigTarget());
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
- const options = [
343
- {
344
- label: 'Project config',
345
- value: 'project',
346
- description: getProjectRuntimeConfigPath(),
347
- },
348
- {
349
- label: 'Shared user config',
350
- value: 'user',
351
- description: getUserRuntimeConfigPath(),
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 = getInitConfigPath(selectedTarget);
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 (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Create a ProtoAgent runtime config:" }), _jsx(Text, { dimColor: true, children: "Select where to write `protoagent.jsonc`." }), _jsx(Text, { dimColor: true, children: activeOption.description }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: options.map((option) => ({ label: option.label, value: option.value })), onChange: (value) => {
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 : {}),