@supatest/cli 0.0.2 → 0.0.3
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 +58 -315
- package/dist/agent-runner.js +224 -52
- package/dist/commands/login.js +392 -0
- package/dist/commands/setup.js +234 -0
- package/dist/config.js +29 -0
- package/dist/core/agent.js +270 -0
- package/dist/index.js +118 -31
- package/dist/modes/headless.js +117 -0
- package/dist/modes/interactive.js +430 -0
- package/dist/presenters/composite.js +32 -0
- package/dist/presenters/console.js +163 -0
- package/dist/presenters/react.js +220 -0
- package/dist/presenters/types.js +1 -0
- package/dist/presenters/web.js +78 -0
- package/dist/prompts/builder.js +181 -0
- package/dist/prompts/fixer.js +148 -0
- package/dist/prompts/headless.md +97 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/interactive.md +43 -0
- package/dist/prompts/plan.md +41 -0
- package/dist/prompts/planner.js +70 -0
- package/dist/prompts/prompts/builder.md +97 -0
- package/dist/prompts/prompts/fixer.md +100 -0
- package/dist/prompts/prompts/plan.md +41 -0
- package/dist/prompts/prompts/planner.md +41 -0
- package/dist/services/api-client.js +244 -0
- package/dist/services/event-streamer.js +130 -0
- package/dist/ui/App.js +322 -0
- package/dist/ui/components/AuthBanner.js +20 -0
- package/dist/ui/components/AuthDialog.js +32 -0
- package/dist/ui/components/Banner.js +12 -0
- package/dist/ui/components/ExpandableSection.js +17 -0
- package/dist/ui/components/Header.js +49 -0
- package/dist/ui/components/HelpMenu.js +89 -0
- package/dist/ui/components/InputPrompt.js +292 -0
- package/dist/ui/components/MessageList.js +42 -0
- package/dist/ui/components/QueuedMessageDisplay.js +31 -0
- package/dist/ui/components/Scrollable.js +103 -0
- package/dist/ui/components/SessionSelector.js +196 -0
- package/dist/ui/components/StatusBar.js +45 -0
- package/dist/ui/components/messages/AssistantMessage.js +20 -0
- package/dist/ui/components/messages/ErrorMessage.js +26 -0
- package/dist/ui/components/messages/LoadingMessage.js +28 -0
- package/dist/ui/components/messages/ThinkingMessage.js +17 -0
- package/dist/ui/components/messages/TodoMessage.js +44 -0
- package/dist/ui/components/messages/ToolMessage.js +218 -0
- package/dist/ui/components/messages/UserMessage.js +14 -0
- package/dist/ui/contexts/KeypressContext.js +527 -0
- package/dist/ui/contexts/MouseContext.js +98 -0
- package/dist/ui/contexts/SessionContext.js +131 -0
- package/dist/ui/hooks/useAnimatedScrollbar.js +83 -0
- package/dist/ui/hooks/useBatchedScroll.js +22 -0
- package/dist/ui/hooks/useBracketedPaste.js +31 -0
- package/dist/ui/hooks/useFocus.js +50 -0
- package/dist/ui/hooks/useKeypress.js +26 -0
- package/dist/ui/hooks/useModeToggle.js +25 -0
- package/dist/ui/types/auth.js +13 -0
- package/dist/ui/utils/file-completion.js +56 -0
- package/dist/ui/utils/input.js +50 -0
- package/dist/ui/utils/markdown.js +376 -0
- package/dist/ui/utils/mouse.js +189 -0
- package/dist/ui/utils/theme.js +59 -0
- package/dist/utils/banner.js +7 -14
- package/dist/utils/encryption.js +71 -0
- package/dist/utils/events.js +36 -0
- package/dist/utils/keychain-storage.js +120 -0
- package/dist/utils/logger.js +103 -1
- package/dist/utils/node-version.js +1 -3
- package/dist/utils/plan-file.js +75 -0
- package/dist/utils/project-instructions.js +23 -0
- package/dist/utils/rich-logger.js +1 -1
- package/dist/utils/stdio.js +80 -0
- package/dist/utils/summary.js +1 -5
- package/dist/utils/token-storage.js +242 -0
- package/package.json +35 -15
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive mode entry point
|
|
3
|
+
* Launches the Ink UI and runs the agent with real-time updates
|
|
4
|
+
*/
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
import React, { useEffect, useRef } from "react";
|
|
7
|
+
import { config as envConfig } from "../config";
|
|
8
|
+
import { CoreAgent } from "../core/agent";
|
|
9
|
+
import { ReactPresenter } from "../presenters/react";
|
|
10
|
+
import { ApiClient, ApiError } from "../services/api-client";
|
|
11
|
+
import { App } from "../ui/App";
|
|
12
|
+
import { KeypressProvider } from "../ui/contexts/KeypressContext";
|
|
13
|
+
import { MouseProvider } from "../ui/contexts/MouseContext";
|
|
14
|
+
import { SessionProvider, useSession } from "../ui/contexts/SessionContext";
|
|
15
|
+
import { useBracketedPaste } from "../ui/hooks/useBracketedPaste";
|
|
16
|
+
import { disableMouseEvents, enableMouseEvents } from "../ui/utils/mouse";
|
|
17
|
+
import { logger } from "../utils/logger";
|
|
18
|
+
import { createInkStdio, patchStdio } from "../utils/stdio";
|
|
19
|
+
const CLI_VERSION = "0.0.1";
|
|
20
|
+
/**
|
|
21
|
+
* Get human-readable description for tool call (used when resuming sessions)
|
|
22
|
+
*/
|
|
23
|
+
function getToolDescription(toolName, input) {
|
|
24
|
+
switch (toolName) {
|
|
25
|
+
case "Read":
|
|
26
|
+
return input?.file_path || "file";
|
|
27
|
+
case "Write":
|
|
28
|
+
return input?.file_path || "file";
|
|
29
|
+
case "Edit":
|
|
30
|
+
return input?.file_path || "file";
|
|
31
|
+
case "Bash": {
|
|
32
|
+
const cmd = input?.command || "";
|
|
33
|
+
return cmd.length > 60 ? `${cmd.substring(0, 60)}...` : cmd;
|
|
34
|
+
}
|
|
35
|
+
case "Glob":
|
|
36
|
+
return `pattern: "${input?.pattern || "files"}"`;
|
|
37
|
+
case "Grep": {
|
|
38
|
+
const pattern = input?.pattern || "code";
|
|
39
|
+
const path = input?.path;
|
|
40
|
+
return path ? `"${pattern}" (in ${path})` : `"${pattern}"`;
|
|
41
|
+
}
|
|
42
|
+
case "Task":
|
|
43
|
+
return input?.subagent_type || "task";
|
|
44
|
+
case "TodoWrite":
|
|
45
|
+
return "Updated todos";
|
|
46
|
+
case "BashOutput":
|
|
47
|
+
case "Command Output":
|
|
48
|
+
return input?.bash_id || "shell output";
|
|
49
|
+
default:
|
|
50
|
+
return toolName;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Agent Runner Component
|
|
55
|
+
* Runs the agent via CoreAgent and updates the UI context in real-time
|
|
56
|
+
*/
|
|
57
|
+
const AgentRunner = ({ config, sessionId, apiClient, onComplete }) => {
|
|
58
|
+
const { addMessage, updateLastMessage, updateMessageByToolId, setIsAgentRunning, updateStats, setTodos, shouldInterruptAgent, setShouldInterruptAgent, agentMode, planFilePath, } = useSession();
|
|
59
|
+
// Keep a ref to the agent so we can call abort()
|
|
60
|
+
const agentRef = useRef(null);
|
|
61
|
+
// When shouldInterruptAgent changes to true, abort the agent
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (shouldInterruptAgent && agentRef.current) {
|
|
64
|
+
agentRef.current.abort();
|
|
65
|
+
setShouldInterruptAgent(false);
|
|
66
|
+
}
|
|
67
|
+
}, [shouldInterruptAgent, setShouldInterruptAgent]);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
let isMounted = true;
|
|
70
|
+
const runAgent = async () => {
|
|
71
|
+
setIsAgentRunning(true);
|
|
72
|
+
try {
|
|
73
|
+
// Set up environment for Claude Code
|
|
74
|
+
const baseUrl = `${config.supatestApiUrl}/v1/sessions/${sessionId}/anthropic`;
|
|
75
|
+
process.env.ANTHROPIC_BASE_URL = baseUrl;
|
|
76
|
+
process.env.ANTHROPIC_API_KEY = config.supatestApiKey;
|
|
77
|
+
// Create presenter with callbacks to React state
|
|
78
|
+
const presenter = new ReactPresenter({
|
|
79
|
+
addMessage: (msg) => {
|
|
80
|
+
if (isMounted)
|
|
81
|
+
addMessage(msg);
|
|
82
|
+
},
|
|
83
|
+
updateLastMessage: (update) => {
|
|
84
|
+
if (isMounted)
|
|
85
|
+
updateLastMessage(update);
|
|
86
|
+
},
|
|
87
|
+
updateMessageByToolId: (toolId, update) => {
|
|
88
|
+
if (isMounted)
|
|
89
|
+
updateMessageByToolId(toolId, update);
|
|
90
|
+
},
|
|
91
|
+
updateStats: (stats) => {
|
|
92
|
+
if (isMounted)
|
|
93
|
+
updateStats(stats);
|
|
94
|
+
},
|
|
95
|
+
setTodos: (todos) => {
|
|
96
|
+
if (isMounted)
|
|
97
|
+
setTodos(todos);
|
|
98
|
+
},
|
|
99
|
+
// Note: onComplete is now called after agent.run() returns
|
|
100
|
+
// to capture the providerSessionId from the result
|
|
101
|
+
onComplete: () => { },
|
|
102
|
+
}, apiClient, sessionId, config.verbose);
|
|
103
|
+
// Build mode-specific configuration
|
|
104
|
+
// Plan mode uses planSystemPrompt and restricts to read-only tools
|
|
105
|
+
const runConfig = {
|
|
106
|
+
...config,
|
|
107
|
+
mode: agentMode,
|
|
108
|
+
planFilePath,
|
|
109
|
+
systemPromptAppend: agentMode === 'plan'
|
|
110
|
+
? envConfig.planSystemPrompt
|
|
111
|
+
: config.systemPromptAppend,
|
|
112
|
+
};
|
|
113
|
+
// Run the agent through CoreAgent
|
|
114
|
+
// Store ref so we can abort from interrupt effect
|
|
115
|
+
const agent = new CoreAgent(presenter);
|
|
116
|
+
agentRef.current = agent;
|
|
117
|
+
const result = await agent.run(runConfig);
|
|
118
|
+
// Pass providerSessionId on completion for storage
|
|
119
|
+
if (isMounted) {
|
|
120
|
+
onComplete(result.success, result.providerSessionId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (isMounted) {
|
|
125
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
126
|
+
addMessage({
|
|
127
|
+
type: "error",
|
|
128
|
+
content: errorMessage,
|
|
129
|
+
errorType: "error",
|
|
130
|
+
});
|
|
131
|
+
await apiClient.streamEvent(sessionId, {
|
|
132
|
+
type: "session_error",
|
|
133
|
+
error: errorMessage,
|
|
134
|
+
});
|
|
135
|
+
onComplete(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (isMounted) {
|
|
140
|
+
setIsAgentRunning(false);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
runAgent();
|
|
145
|
+
return () => {
|
|
146
|
+
isMounted = false;
|
|
147
|
+
agentRef.current = null;
|
|
148
|
+
};
|
|
149
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
150
|
+
return null; // This component doesn't render anything
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Content component for InteractiveApp that has access to SessionContext
|
|
154
|
+
*/
|
|
155
|
+
const InteractiveAppContent = ({ config, sessionId: initialSessionId, webUrl, apiClient, onExit }) => {
|
|
156
|
+
const { addMessage, loadMessages, setSessionId: setContextSessionId, updateStats, } = useSession();
|
|
157
|
+
const [sessionId, setSessionId] = React.useState(initialSessionId);
|
|
158
|
+
const [currentTask, setCurrentTask] = React.useState(config.task);
|
|
159
|
+
const [taskId, setTaskId] = React.useState(0);
|
|
160
|
+
const [shouldRunAgent, setShouldRunAgent] = React.useState(!!config.task);
|
|
161
|
+
const [taskQueue, setTaskQueue] = React.useState([]);
|
|
162
|
+
// Track provider session ID for resume capability
|
|
163
|
+
const [providerSessionId, setProviderSessionId] = React.useState();
|
|
164
|
+
// Track initial tokens for resumed sessions
|
|
165
|
+
const [initialTokens, setInitialTokens] = React.useState(0);
|
|
166
|
+
const handleSubmitTask = async (task) => {
|
|
167
|
+
// Create session on first message if it doesn't exist
|
|
168
|
+
if (!sessionId) {
|
|
169
|
+
try {
|
|
170
|
+
const truncatedTitle = task.length > 50 ? task.slice(0, 50) + "..." : task;
|
|
171
|
+
const session = await apiClient.createSession(truncatedTitle, {
|
|
172
|
+
cliVersion: CLI_VERSION,
|
|
173
|
+
cwd: config.cwd || process.cwd(),
|
|
174
|
+
});
|
|
175
|
+
setSessionId(session.sessionId);
|
|
176
|
+
setContextSessionId(session.sessionId);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const errorMessage = error instanceof ApiError
|
|
180
|
+
? error.message
|
|
181
|
+
: `Failed to create session: ${error instanceof Error ? error.message : String(error)}`;
|
|
182
|
+
addMessage({
|
|
183
|
+
type: "error",
|
|
184
|
+
content: errorMessage,
|
|
185
|
+
errorType: error instanceof ApiError && error.isAuthError ? "warning" : "error",
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (shouldRunAgent) {
|
|
191
|
+
setTaskQueue((prev) => [...prev, task]);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
setCurrentTask(task);
|
|
195
|
+
addMessage({
|
|
196
|
+
type: "user",
|
|
197
|
+
content: task,
|
|
198
|
+
});
|
|
199
|
+
setTaskId((prev) => prev + 1);
|
|
200
|
+
setShouldRunAgent(true);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const handleAgentComplete = async (_success, newProviderSessionId) => {
|
|
204
|
+
setShouldRunAgent(false);
|
|
205
|
+
// Store the provider session ID for future resume capability
|
|
206
|
+
if (sessionId && newProviderSessionId && newProviderSessionId !== providerSessionId) {
|
|
207
|
+
setProviderSessionId(newProviderSessionId);
|
|
208
|
+
try {
|
|
209
|
+
await apiClient.updateSession(sessionId, {
|
|
210
|
+
providerSessionId: newProviderSessionId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Non-critical - session will work without resume capability
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// Process queued tasks when agent becomes idle
|
|
219
|
+
React.useEffect(() => {
|
|
220
|
+
if (!shouldRunAgent && taskQueue.length > 0) {
|
|
221
|
+
const [nextTask, ...remaining] = taskQueue;
|
|
222
|
+
setTaskQueue(remaining);
|
|
223
|
+
setCurrentTask(nextTask);
|
|
224
|
+
addMessage({
|
|
225
|
+
type: "user",
|
|
226
|
+
content: nextTask,
|
|
227
|
+
});
|
|
228
|
+
setTaskId((prev) => prev + 1);
|
|
229
|
+
setShouldRunAgent(true);
|
|
230
|
+
}
|
|
231
|
+
}, [shouldRunAgent, taskQueue, addMessage]);
|
|
232
|
+
return (React.createElement(React.Fragment, null,
|
|
233
|
+
React.createElement(App, { apiClient: apiClient, config: { ...config, task: currentTask }, onExit: onExit, onResumeSession: async (session) => {
|
|
234
|
+
try {
|
|
235
|
+
if (!apiClient) {
|
|
236
|
+
addMessage({
|
|
237
|
+
type: "error",
|
|
238
|
+
content: "API client not available. Cannot resume session.",
|
|
239
|
+
errorType: "error",
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const response = await apiClient.getSessionMessages(session.id);
|
|
244
|
+
const apiMessages = response.messages;
|
|
245
|
+
// First pass: collect all tool_results by tool_use_id
|
|
246
|
+
const toolResults = new Map();
|
|
247
|
+
for (const msg of apiMessages) {
|
|
248
|
+
const contentBlocks = msg.content;
|
|
249
|
+
if (!contentBlocks)
|
|
250
|
+
continue;
|
|
251
|
+
for (const block of contentBlocks) {
|
|
252
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
253
|
+
// Extract text content from tool result
|
|
254
|
+
let resultText = "";
|
|
255
|
+
if (typeof block.content === "string") {
|
|
256
|
+
resultText = block.content;
|
|
257
|
+
}
|
|
258
|
+
else if (Array.isArray(block.content)) {
|
|
259
|
+
resultText = block.content
|
|
260
|
+
.map((c) => c.text || "")
|
|
261
|
+
.join("\n");
|
|
262
|
+
}
|
|
263
|
+
toolResults.set(block.tool_use_id, resultText);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Convert API messages to UI Message format
|
|
268
|
+
const uiMessages = apiMessages.flatMap((msg) => {
|
|
269
|
+
const messages = [];
|
|
270
|
+
const contentBlocks = msg.content;
|
|
271
|
+
if (!contentBlocks || contentBlocks.length === 0) {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
for (const block of contentBlocks) {
|
|
275
|
+
if (block.type === "text") {
|
|
276
|
+
messages.push({
|
|
277
|
+
id: `${msg.id}-${messages.length}`,
|
|
278
|
+
type: msg.role,
|
|
279
|
+
content: block.text,
|
|
280
|
+
timestamp: new Date(msg.createdAt).getTime(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
else if (block.type === "thinking") {
|
|
284
|
+
messages.push({
|
|
285
|
+
id: `${msg.id}-${messages.length}`,
|
|
286
|
+
type: "thinking",
|
|
287
|
+
content: block.thinking,
|
|
288
|
+
timestamp: new Date(msg.createdAt).getTime(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
else if (block.type === "tool_use") {
|
|
292
|
+
// Look up the tool result for this tool_use
|
|
293
|
+
const toolResult = toolResults.get(block.id);
|
|
294
|
+
messages.push({
|
|
295
|
+
id: `${msg.id}-${messages.length}`,
|
|
296
|
+
type: "tool",
|
|
297
|
+
content: getToolDescription(block.name, block.input),
|
|
298
|
+
toolName: block.name,
|
|
299
|
+
toolInput: block.input,
|
|
300
|
+
toolResult: toolResult,
|
|
301
|
+
toolUseId: block.id,
|
|
302
|
+
timestamp: new Date(msg.createdAt).getTime(),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return messages;
|
|
307
|
+
});
|
|
308
|
+
setSessionId(session.id);
|
|
309
|
+
setContextSessionId(session.id);
|
|
310
|
+
// Set the provider session ID for resume capability
|
|
311
|
+
// The session object may have providerSessionId from the API
|
|
312
|
+
if (session.providerSessionId) {
|
|
313
|
+
setProviderSessionId(session.providerSessionId);
|
|
314
|
+
}
|
|
315
|
+
// Fetch session details to get totalTokens for resume
|
|
316
|
+
try {
|
|
317
|
+
const sessionDetails = await apiClient.getSession(session.id);
|
|
318
|
+
if (sessionDetails.totalTokens) {
|
|
319
|
+
setInitialTokens(sessionDetails.totalTokens);
|
|
320
|
+
updateStats({ totalTokens: sessionDetails.totalTokens });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Non-critical - stats will start from 0
|
|
325
|
+
}
|
|
326
|
+
uiMessages.push({
|
|
327
|
+
id: `resume-info-${Date.now()}`,
|
|
328
|
+
type: "error",
|
|
329
|
+
content: `Resumed session: ${session.title || "Untitled"} (${apiMessages.length} messages loaded)`,
|
|
330
|
+
errorType: "info",
|
|
331
|
+
timestamp: Date.now(),
|
|
332
|
+
});
|
|
333
|
+
loadMessages(uiMessages);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const errorMessage = error instanceof ApiError
|
|
337
|
+
? error.message
|
|
338
|
+
: `Failed to resume session: ${error instanceof Error ? error.message : String(error)}`;
|
|
339
|
+
addMessage({
|
|
340
|
+
type: "error",
|
|
341
|
+
content: errorMessage,
|
|
342
|
+
errorType: error instanceof ApiError && error.isAuthError
|
|
343
|
+
? "warning"
|
|
344
|
+
: "error",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}, onSubmitTask: handleSubmitTask, queuedTasks: taskQueue, sessionId: sessionId, webUrl: webUrl }),
|
|
348
|
+
shouldRunAgent && currentTask && sessionId && (React.createElement(AgentRunner, { apiClient: apiClient, config: { ...config, task: currentTask, providerSessionId, initialTokens }, key: `${taskId}`, onComplete: handleAgentComplete, sessionId: sessionId }))));
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* Main wrapper component that combines UI and agent
|
|
352
|
+
*/
|
|
353
|
+
const InteractiveApp = (props) => {
|
|
354
|
+
useBracketedPaste();
|
|
355
|
+
return (React.createElement(KeypressProvider, null,
|
|
356
|
+
React.createElement(MouseProvider, { mouseEventsEnabled: true },
|
|
357
|
+
React.createElement(SessionProvider, null,
|
|
358
|
+
React.createElement(InteractiveAppContent, { ...props })))));
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Run the interactive mode with Ink UI
|
|
362
|
+
*/
|
|
363
|
+
export async function runInteractive(config) {
|
|
364
|
+
let success = false;
|
|
365
|
+
let unmountFn = null;
|
|
366
|
+
let isExiting = false;
|
|
367
|
+
const cleanupStdio = patchStdio();
|
|
368
|
+
const gracefulExit = async (exitCode) => {
|
|
369
|
+
if (isExiting)
|
|
370
|
+
return;
|
|
371
|
+
isExiting = true;
|
|
372
|
+
disableMouseEvents();
|
|
373
|
+
cleanupStdio();
|
|
374
|
+
if (unmountFn) {
|
|
375
|
+
unmountFn();
|
|
376
|
+
}
|
|
377
|
+
process.exit(exitCode);
|
|
378
|
+
};
|
|
379
|
+
const handleSigInt = async () => {
|
|
380
|
+
await gracefulExit(0);
|
|
381
|
+
};
|
|
382
|
+
process.on("SIGINT", handleSigInt);
|
|
383
|
+
process.on("SIGTERM", handleSigInt);
|
|
384
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
385
|
+
logger.enableFileLogging(isDev);
|
|
386
|
+
const apiUrl = config.supatestApiUrl || "https://api.supatest.ai";
|
|
387
|
+
const apiClient = new ApiClient(apiUrl, config.supatestApiKey);
|
|
388
|
+
try {
|
|
389
|
+
process.stdout.write("\x1Bc");
|
|
390
|
+
console.clear();
|
|
391
|
+
enableMouseEvents();
|
|
392
|
+
const { stdout: inkStdout, stderr: inkStderr } = createInkStdio();
|
|
393
|
+
let sessionId;
|
|
394
|
+
let webUrl;
|
|
395
|
+
if (config.task) {
|
|
396
|
+
const truncatedTitle = config.task.length > 50 ? config.task.slice(0, 50) + "..." : config.task;
|
|
397
|
+
const session = await apiClient.createSession(truncatedTitle, {
|
|
398
|
+
cliVersion: CLI_VERSION,
|
|
399
|
+
cwd: config.cwd || process.cwd(),
|
|
400
|
+
});
|
|
401
|
+
sessionId = session.sessionId;
|
|
402
|
+
webUrl = session.webUrl;
|
|
403
|
+
}
|
|
404
|
+
const { unmount, waitUntilExit } = render(React.createElement(InteractiveApp, { apiClient: apiClient, config: config, onExit: (exitSuccess) => {
|
|
405
|
+
success = exitSuccess;
|
|
406
|
+
}, sessionId: sessionId, webUrl: webUrl }), {
|
|
407
|
+
stdout: inkStdout,
|
|
408
|
+
stderr: inkStderr,
|
|
409
|
+
stdin: process.stdin,
|
|
410
|
+
alternateBuffer: true,
|
|
411
|
+
exitOnCtrlC: false,
|
|
412
|
+
});
|
|
413
|
+
unmountFn = unmount;
|
|
414
|
+
await waitUntilExit();
|
|
415
|
+
unmount();
|
|
416
|
+
disableMouseEvents();
|
|
417
|
+
cleanupStdio();
|
|
418
|
+
process.off("SIGINT", handleSigInt);
|
|
419
|
+
process.off("SIGTERM", handleSigInt);
|
|
420
|
+
process.exit(success ? 0 : 1);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
disableMouseEvents();
|
|
424
|
+
cleanupStdio();
|
|
425
|
+
process.off("SIGINT", handleSigInt);
|
|
426
|
+
process.off("SIGTERM", handleSigInt);
|
|
427
|
+
console.error("Failed to start interactive mode:", error instanceof Error ? error.message : String(error));
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class CompositePresenter {
|
|
2
|
+
presenters;
|
|
3
|
+
constructor(presenters) {
|
|
4
|
+
this.presenters = presenters;
|
|
5
|
+
}
|
|
6
|
+
async onStart(config) {
|
|
7
|
+
await Promise.all(this.presenters.map((p) => p.onStart(config)));
|
|
8
|
+
}
|
|
9
|
+
onLog(message) {
|
|
10
|
+
for (const p of this.presenters) {
|
|
11
|
+
p.onLog(message);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async onAssistantText(text) {
|
|
15
|
+
await Promise.all(this.presenters.map((p) => p.onAssistantText(text)));
|
|
16
|
+
}
|
|
17
|
+
async onThinking(text) {
|
|
18
|
+
await Promise.all(this.presenters.map((p) => p.onThinking(text)));
|
|
19
|
+
}
|
|
20
|
+
async onToolUse(tool, input, toolId) {
|
|
21
|
+
await Promise.all(this.presenters.map((p) => p.onToolUse(tool, input, toolId)));
|
|
22
|
+
}
|
|
23
|
+
async onTurnComplete(content) {
|
|
24
|
+
await Promise.all(this.presenters.map((p) => p.onTurnComplete(content)));
|
|
25
|
+
}
|
|
26
|
+
async onError(error) {
|
|
27
|
+
await Promise.all(this.presenters.map((p) => p.onError(error)));
|
|
28
|
+
}
|
|
29
|
+
async onComplete(result) {
|
|
30
|
+
await Promise.all(this.presenters.map((p) => p.onComplete(result)));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { getToolDisplayName } from "shared";
|
|
4
|
+
import { logger } from "../utils/logger";
|
|
5
|
+
import { generateSummary } from "../utils/summary";
|
|
6
|
+
// Fun spinner messages that rotate randomly
|
|
7
|
+
const SPINNER_MESSAGES = [
|
|
8
|
+
"Brainstorming...",
|
|
9
|
+
"Brewing coffee...",
|
|
10
|
+
"Sipping espresso...",
|
|
11
|
+
"Testing theories...",
|
|
12
|
+
"Making magic...",
|
|
13
|
+
"Multiplying matrices...",
|
|
14
|
+
];
|
|
15
|
+
function getRandomSpinnerMessage() {
|
|
16
|
+
return SPINNER_MESSAGES[Math.floor(Math.random() * SPINNER_MESSAGES.length)];
|
|
17
|
+
}
|
|
18
|
+
function createShimmerFrames(text) {
|
|
19
|
+
const frames = [];
|
|
20
|
+
const baseText = text;
|
|
21
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
22
|
+
for (let i = 0; i <= baseText.length; i++) {
|
|
23
|
+
const spinnerIcon = spinnerFrames[i % spinnerFrames.length];
|
|
24
|
+
const before = chalk.white(baseText.slice(0, i));
|
|
25
|
+
const current = baseText[i] || "";
|
|
26
|
+
const after = chalk.white(baseText.slice(i + 1));
|
|
27
|
+
const shimmerText = before + chalk.cyan.bold(current) + after;
|
|
28
|
+
frames.push(`${chalk.cyan(spinnerIcon)} ${shimmerText}`);
|
|
29
|
+
}
|
|
30
|
+
return frames;
|
|
31
|
+
}
|
|
32
|
+
export class ConsolePresenter {
|
|
33
|
+
spinner = null;
|
|
34
|
+
verbose;
|
|
35
|
+
stats;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.verbose = options.verbose;
|
|
38
|
+
this.stats = {
|
|
39
|
+
startTime: Date.now(),
|
|
40
|
+
iterations: 0,
|
|
41
|
+
filesModified: new Set(),
|
|
42
|
+
commandsRun: [],
|
|
43
|
+
errors: [],
|
|
44
|
+
};
|
|
45
|
+
logger.setVerbose(options.verbose);
|
|
46
|
+
}
|
|
47
|
+
onStart(config) {
|
|
48
|
+
logger.raw("");
|
|
49
|
+
logger.raw(chalk.white.bold("Task:") + " " + chalk.cyan(config.task));
|
|
50
|
+
if (config.logs) {
|
|
51
|
+
logger.info("Processing provided logs...");
|
|
52
|
+
}
|
|
53
|
+
logger.raw("");
|
|
54
|
+
this.startSpinner();
|
|
55
|
+
}
|
|
56
|
+
onLog(message) {
|
|
57
|
+
if (this.verbose) {
|
|
58
|
+
this.stopSpinner();
|
|
59
|
+
logger.debug(message);
|
|
60
|
+
this.startSpinner();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
onAssistantText(text) {
|
|
64
|
+
this.stopSpinner();
|
|
65
|
+
logger.raw(text);
|
|
66
|
+
this.startSpinner();
|
|
67
|
+
}
|
|
68
|
+
onThinking(text) {
|
|
69
|
+
if (this.verbose) {
|
|
70
|
+
this.stopSpinner();
|
|
71
|
+
logger.debug(`Thinking: ${text}`);
|
|
72
|
+
this.startSpinner();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
onToolUse(tool, input, _toolId) {
|
|
76
|
+
if (this.spinner) {
|
|
77
|
+
const displayName = getToolDisplayName(tool);
|
|
78
|
+
this.spinner.text = `Using ${displayName}...`;
|
|
79
|
+
}
|
|
80
|
+
this.stopSpinner();
|
|
81
|
+
if (tool === "Read") {
|
|
82
|
+
logger.toolRead(input?.file_path || "file");
|
|
83
|
+
}
|
|
84
|
+
else if (tool === "Write") {
|
|
85
|
+
const filePath = input?.file_path;
|
|
86
|
+
if (filePath) {
|
|
87
|
+
this.stats.filesModified.add(filePath);
|
|
88
|
+
logger.toolWrite(filePath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (tool === "Edit") {
|
|
92
|
+
const filePath = input?.file_path;
|
|
93
|
+
if (filePath) {
|
|
94
|
+
this.stats.filesModified.add(filePath);
|
|
95
|
+
logger.toolEdit(filePath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (tool === "Bash") {
|
|
99
|
+
const command = input?.command;
|
|
100
|
+
if (command) {
|
|
101
|
+
this.stats.commandsRun.push(command);
|
|
102
|
+
const shortCmd = command.length > 60 ? `${command.substring(0, 60)}...` : command;
|
|
103
|
+
logger.toolBash(shortCmd);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (tool === "Glob") {
|
|
107
|
+
logger.toolSearch("files", input?.pattern || "");
|
|
108
|
+
}
|
|
109
|
+
else if (tool === "Grep") {
|
|
110
|
+
logger.toolSearch("code", input?.pattern || "");
|
|
111
|
+
}
|
|
112
|
+
else if (tool === "Task") {
|
|
113
|
+
logger.toolAgent(input?.subagent_type || "task");
|
|
114
|
+
}
|
|
115
|
+
else if (tool === "TodoWrite") {
|
|
116
|
+
const todos = input?.todos;
|
|
117
|
+
if (Array.isArray(todos)) {
|
|
118
|
+
logger.todoUpdate(todos);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
logger.info("📝 Updated todos");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
logger.debug(`🔧 Using tool: ${tool}`);
|
|
126
|
+
}
|
|
127
|
+
logger.raw("");
|
|
128
|
+
this.startSpinner();
|
|
129
|
+
}
|
|
130
|
+
onTurnComplete(_content) {
|
|
131
|
+
// No-op for console - we handle output in individual callbacks
|
|
132
|
+
}
|
|
133
|
+
onError(error) {
|
|
134
|
+
this.stopSpinner();
|
|
135
|
+
logger.error(error);
|
|
136
|
+
this.stats.errors.push(error);
|
|
137
|
+
}
|
|
138
|
+
onComplete(result) {
|
|
139
|
+
this.stopSpinner();
|
|
140
|
+
this.stats.endTime = Date.now();
|
|
141
|
+
this.stats.iterations = result.iterations;
|
|
142
|
+
const summaryText = generateSummary(this.stats, result, this.verbose);
|
|
143
|
+
logger.raw(summaryText);
|
|
144
|
+
}
|
|
145
|
+
startSpinner() {
|
|
146
|
+
if (!this.spinner && !logger.isSilent()) {
|
|
147
|
+
const message = getRandomSpinnerMessage();
|
|
148
|
+
this.spinner = ora({
|
|
149
|
+
spinner: {
|
|
150
|
+
interval: 80,
|
|
151
|
+
frames: createShimmerFrames(message),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
this.spinner.start();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
stopSpinner() {
|
|
158
|
+
if (this.spinner) {
|
|
159
|
+
this.spinner.stop();
|
|
160
|
+
this.spinner = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|