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/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
- Here's how the terminal UI is laid out (showcasing all options at once for demonstration, but in practice many elements are conditional on state):
9
+ ACTUAL UI Layout:
10
10
  ┌─────────────────────────────────────────┐
11
- │ ProtoAgent (BigText logo) │ static, rendered once (printBanner)
12
- │ Model: Anthropic / claude-3-5 | Sess.. │ static header (printRuntimeHeader)
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
- [System Prompt collapsed] archived (memoized)
18
+ Archived messages (Static scrollback):
19
+ │ > user message │
20
+ │ assistant reply text │
21
+ │ ▶ tool_name: result preview... │
17
22
  │ │
18
- │ > user message │ archived (memoized)
23
+ ─ ─ ─ live boundary ─ ─ ─ ─ ─ ─ ─ ─ ┤
19
24
  │ │
20
- │ assistant reply text archived (memoized)
25
+ │ assistant streaming text...▍ live (re-renders per token)
21
26
  │ │
22
- [tool_name collapsed] archived (memoized)
27
+ Running read_file... live, spinner + active tool
23
28
  │ │
24
- > user message archived (memoized)
25
- │ │
26
- ├ ─ ─ ─ ─ ─ ─ ─ live boundary ─ ─ ─ ─ ─ ─ ┤
27
- │ │
28
- │ assistant streaming text... │ live (re-renders, ~50ms debounce)
29
- │ │
30
- │ [tool_name ▸ collapsed] │ live (re-renders on tool_result)
31
- │ │
32
- │ Thinking... │ live, only if last msg is user
33
- │ │
34
- │ ╭─ Approval Required ─────────────────╮ │ live, only when pending approval
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 │ static-ish, updates after each turn
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 { TextInput, Select } from '@inkjs/ui';
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, writeConfig, writeInitConfig, resolveApiKey, TargetSelection, ModelSelection, ApiKeyInput } from './config.js';
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
- // ─── Scrollback helpers ───
75
- // These functions append text to the permanent scrollback buffer via the
76
- // <Static> component. Ink flushes new Static items within its own render
77
- // cycle, so there are no timing issues with write()/log-update.
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
- addStatic(_jsxs(Text, { children: [_jsx(Text, { color: "#09A469", children: "\u2588\u2580\u2588 \u2588\u2580\u2588 \u2588\u2580\u2588 \u2580\u2588\u2580 \u2588\u2580\u2588 \u2584\u2580\u2588 \u2588\u2580\u2580 \u2588\u2580\u2580 \u2588\u2584 \u2588 \u2580\u2588\u2580" }), '\n', _jsx(Text, { color: "#09A469", children: "\u2588\u2580\u2580 \u2588\u2580\u2584 \u2588\u2584\u2588 \u2588 \u2588\u2584\u2588 \u2588\u2580\u2588 \u2588\u2584\u2588 \u2588\u2588\u2584 \u2588 \u2580\u2588 \u2588" }), '\n'] }));
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 line = `Model: ${provider?.name || config.provider} / ${config.model}`;
88
+ let modelLine = `Model: ${provider?.name || config.provider} / ${config.model}`;
84
89
  if (dangerouslySkipPermissions)
85
- line += ' (auto-approve all)';
90
+ modelLine += ' (auto-approve all)';
86
91
  if (session)
87
- line += ` | Session: ${session.id}`;
88
- const lines = [_jsx(Text, { dimColor: true, children: line }, "model")];
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
- // Apply Markdown formatting (bold, italic) to assistant messages
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
- addStatic(_jsxs(Text, { dimColor: true, children: ['▶ ', toolName, ': ', compact, '\n'] }));
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
- // Returns only the last N displayable lines of text so the live streaming box
206
- // never grows taller than the terminal, preventing Ink's clearTerminal wipe.
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
- // ─── Available slash commands ───
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
- ' /help - Show this help',
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 appended here is rendered once by <Static> and permanently
331
- // flushed to the terminal scrollback by Ink, within its own render cycle.
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, (event) => {
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}`);