@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,244 @@
|
|
|
1
|
+
import { logger } from "../utils/logger";
|
|
2
|
+
/**
|
|
3
|
+
* API Error class with user-friendly messages
|
|
4
|
+
*
|
|
5
|
+
* Based on Gemini CLI (Apache 2.0 License)
|
|
6
|
+
* https://github.com/google-gemini/gemini-cli
|
|
7
|
+
* Copyright 2025 Google LLC
|
|
8
|
+
*/
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
isAuthError;
|
|
12
|
+
constructor(status, statusText, body) {
|
|
13
|
+
let message;
|
|
14
|
+
if (status === 401) {
|
|
15
|
+
message = "Authentication required. Use /login to authenticate.";
|
|
16
|
+
}
|
|
17
|
+
else if (status === 403) {
|
|
18
|
+
message = "Access denied. Your token may have been revoked.";
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
message = `API error: ${status} ${statusText}`;
|
|
22
|
+
if (body) {
|
|
23
|
+
// Try to extract a cleaner error message from JSON body
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(body);
|
|
26
|
+
if (parsed.error) {
|
|
27
|
+
message = parsed.error;
|
|
28
|
+
}
|
|
29
|
+
else if (parsed.message) {
|
|
30
|
+
message = parsed.message;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// If not JSON, append raw body if short
|
|
35
|
+
if (body.length < 200) {
|
|
36
|
+
message += ` - ${body}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "ApiError";
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.isAuthError = status === 401 || status === 403;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* API Client for CLI to communicate with Supatest backend
|
|
49
|
+
*/
|
|
50
|
+
export class ApiClient {
|
|
51
|
+
apiUrl;
|
|
52
|
+
apiKey;
|
|
53
|
+
constructor(apiUrl, apiKey) {
|
|
54
|
+
this.apiUrl = apiUrl;
|
|
55
|
+
this.apiKey = apiKey;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Update the API key (used when user logs in during session)
|
|
59
|
+
*/
|
|
60
|
+
setApiKey(apiKey) {
|
|
61
|
+
this.apiKey = apiKey;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Clear the API key (used when user logs out)
|
|
65
|
+
*/
|
|
66
|
+
clearApiKey() {
|
|
67
|
+
this.apiKey = undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check if the client has an API key set
|
|
71
|
+
*/
|
|
72
|
+
hasApiKey() {
|
|
73
|
+
return !!this.apiKey;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Create a new CLI session on the backend
|
|
77
|
+
* @param title - The session title
|
|
78
|
+
* @param originMetadata - Optional metadata about the session origin
|
|
79
|
+
* @returns The session ID and web URL
|
|
80
|
+
*/
|
|
81
|
+
async createSession(title, originMetadata) {
|
|
82
|
+
const url = `${this.apiUrl}/v1/agent/sessions`;
|
|
83
|
+
const response = await fetch(url, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
title,
|
|
91
|
+
originMetadata,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const errorText = await response.text();
|
|
96
|
+
throw new ApiError(response.status, response.statusText, errorText);
|
|
97
|
+
}
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Stream an event to the backend
|
|
103
|
+
* @param sessionId - The session ID
|
|
104
|
+
* @param event - The CLI event to stream
|
|
105
|
+
* @returns Success status
|
|
106
|
+
*/
|
|
107
|
+
async streamEvent(sessionId, event) {
|
|
108
|
+
const url = `${this.apiUrl}/v1/agent/sessions/${sessionId}/events`;
|
|
109
|
+
const response = await fetch(url, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(event),
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const errorText = await response.text();
|
|
119
|
+
throw new ApiError(response.status, response.statusText, errorText);
|
|
120
|
+
}
|
|
121
|
+
const data = await response.json();
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get session details from the backend
|
|
126
|
+
* @param sessionId - The session ID
|
|
127
|
+
* @returns Session details
|
|
128
|
+
*/
|
|
129
|
+
async getSession(sessionId) {
|
|
130
|
+
const url = `${this.apiUrl}/v1/agent/sessions/${sessionId}`;
|
|
131
|
+
const response = await fetch(url, {
|
|
132
|
+
method: "GET",
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const errorText = await response.text();
|
|
139
|
+
throw new ApiError(response.status, response.statusText, errorText);
|
|
140
|
+
}
|
|
141
|
+
return await response.json();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get paginated sessions accessible to the user
|
|
145
|
+
* @param limit - Maximum number of sessions to return
|
|
146
|
+
* @param offset - Number of sessions to skip
|
|
147
|
+
* @returns Paginated sessions with total count
|
|
148
|
+
*/
|
|
149
|
+
async getSessions(limit, offset) {
|
|
150
|
+
const urlParams = new URLSearchParams({
|
|
151
|
+
limit: limit.toString(),
|
|
152
|
+
offset: offset.toString(),
|
|
153
|
+
});
|
|
154
|
+
const url = `${this.apiUrl}/v1/sessions?${urlParams.toString()}`;
|
|
155
|
+
logger.debug(`Fetching sessions: ${url}`);
|
|
156
|
+
const response = await fetch(url, {
|
|
157
|
+
method: "GET",
|
|
158
|
+
headers: {
|
|
159
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const errorText = await response.text();
|
|
164
|
+
throw new ApiError(response.status, response.statusText, errorText);
|
|
165
|
+
}
|
|
166
|
+
const data = await response.json();
|
|
167
|
+
logger.debug(`Fetched ${data.sessions.length} sessions (${data.pagination.offset + 1}-${data.pagination.offset + data.sessions.length} of ${data.pagination.total})`);
|
|
168
|
+
return data;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Complete usage tracking for a message turn
|
|
172
|
+
* @param messageId - The assistant message ID
|
|
173
|
+
* @returns Success status
|
|
174
|
+
*/
|
|
175
|
+
async completeUsage(messageId) {
|
|
176
|
+
const url = `${this.apiUrl}/v1/usage/complete`;
|
|
177
|
+
const response = await fetch(url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({ messageId }),
|
|
184
|
+
});
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const errorText = await response.text();
|
|
187
|
+
logger.warn(`Failed to complete usage tracking: ${response.status} ${response.statusText} - ${errorText}`);
|
|
188
|
+
// Don't throw - this is best effort
|
|
189
|
+
return { success: false };
|
|
190
|
+
}
|
|
191
|
+
const data = await response.json();
|
|
192
|
+
return data;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get messages for a session
|
|
196
|
+
* @param sessionId - The session ID
|
|
197
|
+
* @returns Messages with pagination info
|
|
198
|
+
*/
|
|
199
|
+
async getSessionMessages(sessionId) {
|
|
200
|
+
const url = `${this.apiUrl}/v1/sessions/${sessionId}/messages`;
|
|
201
|
+
logger.debug(`Fetching messages for session: ${sessionId}`);
|
|
202
|
+
const response = await fetch(url, {
|
|
203
|
+
method: "GET",
|
|
204
|
+
headers: {
|
|
205
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const errorText = await response.text();
|
|
210
|
+
throw new ApiError(response.status, response.statusText, errorText);
|
|
211
|
+
}
|
|
212
|
+
const data = await response.json();
|
|
213
|
+
logger.debug(`Fetched ${data.messages.length} messages for session: ${sessionId}`);
|
|
214
|
+
return data;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Update a session (title, providerSessionId)
|
|
218
|
+
* @param sessionId - The session ID
|
|
219
|
+
* @param data - The data to update
|
|
220
|
+
* @returns Updated session
|
|
221
|
+
*/
|
|
222
|
+
async updateSession(sessionId, data) {
|
|
223
|
+
const url = `${this.apiUrl}/v1/sessions/${sessionId}`;
|
|
224
|
+
logger.debug(`Updating session: ${sessionId}`, {
|
|
225
|
+
hasTitle: !!data.title,
|
|
226
|
+
hasProviderSessionId: !!data.providerSessionId,
|
|
227
|
+
});
|
|
228
|
+
const response = await fetch(url, {
|
|
229
|
+
method: "PATCH",
|
|
230
|
+
headers: {
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify(data),
|
|
235
|
+
});
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
const errorText = await response.text();
|
|
238
|
+
throw new ApiError(response.status, response.statusText, errorText);
|
|
239
|
+
}
|
|
240
|
+
const result = await response.json();
|
|
241
|
+
logger.debug(`Session updated: ${sessionId}`);
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { logger } from "../utils/logger";
|
|
2
|
+
const BATCH_SIZE = 10; // Send events every 10 events
|
|
3
|
+
const BATCH_INTERVAL_MS = 100; // Or every 100ms, whichever comes first
|
|
4
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
5
|
+
const RETRY_DELAY_MS = 1000; // Start with 1s delay
|
|
6
|
+
/**
|
|
7
|
+
* Event Streamer - Batches and streams CLI events to the backend with retry logic
|
|
8
|
+
*/
|
|
9
|
+
export class EventStreamer {
|
|
10
|
+
apiClient;
|
|
11
|
+
sessionId;
|
|
12
|
+
eventQueue = [];
|
|
13
|
+
batchTimer = null;
|
|
14
|
+
isFlushing = false;
|
|
15
|
+
isShutdown = false;
|
|
16
|
+
constructor(apiClient, sessionId) {
|
|
17
|
+
this.apiClient = apiClient;
|
|
18
|
+
this.sessionId = sessionId;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Queue an event for streaming
|
|
22
|
+
* @param event - The CLI event to queue
|
|
23
|
+
*/
|
|
24
|
+
async queueEvent(event) {
|
|
25
|
+
if (this.isShutdown) {
|
|
26
|
+
logger.warn("EventStreamer is shutdown, cannot queue event");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Send streaming delta events immediately for real-time updates
|
|
30
|
+
// Batch other events to reduce API calls
|
|
31
|
+
if (event.type === "assistant_text" || event.type === "assistant_thinking") {
|
|
32
|
+
await this.sendEventWithRetry(event);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.eventQueue.push(event);
|
|
36
|
+
// Flush if we've reached batch size
|
|
37
|
+
if (this.eventQueue.length >= BATCH_SIZE) {
|
|
38
|
+
await this.flush();
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Reset the batch timer
|
|
42
|
+
this.resetBatchTimer();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reset the batch timer
|
|
47
|
+
*/
|
|
48
|
+
resetBatchTimer() {
|
|
49
|
+
if (this.batchTimer) {
|
|
50
|
+
clearTimeout(this.batchTimer);
|
|
51
|
+
}
|
|
52
|
+
this.batchTimer = setTimeout(() => {
|
|
53
|
+
this.flush().catch((error) => {
|
|
54
|
+
logger.error(`Failed to flush events on timer: ${error.message}`);
|
|
55
|
+
});
|
|
56
|
+
}, BATCH_INTERVAL_MS);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Flush all queued events to the backend
|
|
60
|
+
*/
|
|
61
|
+
async flush() {
|
|
62
|
+
if (this.isFlushing || this.eventQueue.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.isFlushing = true;
|
|
66
|
+
// Clear the batch timer
|
|
67
|
+
if (this.batchTimer) {
|
|
68
|
+
clearTimeout(this.batchTimer);
|
|
69
|
+
this.batchTimer = null;
|
|
70
|
+
}
|
|
71
|
+
// Take all events from the queue
|
|
72
|
+
const eventsToSend = [...this.eventQueue];
|
|
73
|
+
this.eventQueue = [];
|
|
74
|
+
// Send each event with retry logic
|
|
75
|
+
for (const event of eventsToSend) {
|
|
76
|
+
await this.sendEventWithRetry(event);
|
|
77
|
+
}
|
|
78
|
+
this.isFlushing = false;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Send a single event with retry logic
|
|
82
|
+
* @param event - The event to send
|
|
83
|
+
*/
|
|
84
|
+
async sendEventWithRetry(event) {
|
|
85
|
+
let attempts = 0;
|
|
86
|
+
let lastError = null;
|
|
87
|
+
while (attempts < MAX_RETRY_ATTEMPTS) {
|
|
88
|
+
try {
|
|
89
|
+
await this.apiClient.streamEvent(this.sessionId, event);
|
|
90
|
+
return; // Success
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
lastError = error;
|
|
94
|
+
attempts++;
|
|
95
|
+
if (attempts < MAX_RETRY_ATTEMPTS) {
|
|
96
|
+
const delay = RETRY_DELAY_MS * Math.pow(2, attempts - 1); // Exponential backoff
|
|
97
|
+
logger.warn(`Failed to stream event (attempt ${attempts}/${MAX_RETRY_ATTEMPTS}), retrying in ${delay}ms...`);
|
|
98
|
+
await this.sleep(delay);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// All retries failed
|
|
103
|
+
logger.error(`Failed to stream event after ${MAX_RETRY_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
104
|
+
// Don't throw - continue processing other events
|
|
105
|
+
// The event is lost, but we don't want to crash the CLI
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Sleep for a given duration
|
|
109
|
+
* @param ms - Duration in milliseconds
|
|
110
|
+
*/
|
|
111
|
+
sleep(ms) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Shutdown the event streamer and flush any remaining events
|
|
116
|
+
*/
|
|
117
|
+
async shutdown() {
|
|
118
|
+
if (this.isShutdown) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.isShutdown = true;
|
|
122
|
+
// Clear the batch timer
|
|
123
|
+
if (this.batchTimer) {
|
|
124
|
+
clearTimeout(this.batchTimer);
|
|
125
|
+
this.batchTimer = null;
|
|
126
|
+
}
|
|
127
|
+
// Flush any remaining events
|
|
128
|
+
await this.flush();
|
|
129
|
+
}
|
|
130
|
+
}
|
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main App Component
|
|
3
|
+
* Root component for the interactive CLI UI
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
8
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { loginCommand } from "../commands/login.js";
|
|
10
|
+
import { setupCommand } from "../commands/setup.js";
|
|
11
|
+
import { isLoggedIn, removeToken, saveToken, } from "../utils/token-storage.js";
|
|
12
|
+
import { AuthBanner } from "./components/AuthBanner.js";
|
|
13
|
+
import { AuthDialog } from "./components/AuthDialog.js";
|
|
14
|
+
import { HelpMenu } from "./components/HelpMenu.js";
|
|
15
|
+
import { InputPrompt } from "./components/InputPrompt.js";
|
|
16
|
+
import { MessageList } from "./components/MessageList.js";
|
|
17
|
+
import { QueuedMessageDisplay } from "./components/QueuedMessageDisplay.js";
|
|
18
|
+
import { SessionSelector } from "./components/SessionSelector.js";
|
|
19
|
+
import { useSession } from "./contexts/SessionContext.js";
|
|
20
|
+
import { useModeToggle } from "./hooks/useModeToggle.js";
|
|
21
|
+
import { AuthState } from "./types/auth.js";
|
|
22
|
+
const getGitBranch = () => {
|
|
23
|
+
try {
|
|
24
|
+
return execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const getCurrentFolder = () => {
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
const home = homedir();
|
|
33
|
+
if (cwd.startsWith(home)) {
|
|
34
|
+
return `~${cwd.slice(home.length)}`;
|
|
35
|
+
}
|
|
36
|
+
return cwd;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Main app content (inside SessionProvider)
|
|
40
|
+
*/
|
|
41
|
+
const AppContent = ({ config, sessionId, webUrl, queuedTasks = [], onExit, onSubmitTask, apiClient, onResumeSession }) => {
|
|
42
|
+
const { exit } = useApp();
|
|
43
|
+
const { addMessage, clearMessages, isAgentRunning, messages, setSessionId, setWebUrl, setShouldInterruptAgent, toggleAllToolOutputs, allToolsExpanded } = useSession();
|
|
44
|
+
// Enable Shift+Tab mode toggle
|
|
45
|
+
useModeToggle();
|
|
46
|
+
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
|
|
47
|
+
const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows || 24);
|
|
48
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
49
|
+
const [showInput, setShowInput] = useState(true); // Always show input
|
|
50
|
+
const [gitBranch] = useState(() => getGitBranch());
|
|
51
|
+
const [currentFolder] = useState(() => getCurrentFolder());
|
|
52
|
+
const [hasInputContent, setHasInputContent] = useState(false);
|
|
53
|
+
const [exitWarning, setExitWarning] = useState(null);
|
|
54
|
+
const inputPromptRef = useRef(null);
|
|
55
|
+
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
|
56
|
+
// Auth state
|
|
57
|
+
const [authState, setAuthState] = useState(() => isLoggedIn() ? AuthState.Authenticated : AuthState.Unauthenticated);
|
|
58
|
+
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
|
59
|
+
// Show auth dialog on first start if unauthenticated and no API key provided
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!isLoggedIn() && !config.supatestApiKey) {
|
|
62
|
+
setShowAuthDialog(true);
|
|
63
|
+
}
|
|
64
|
+
}, [config.supatestApiKey]);
|
|
65
|
+
// Initialize session data
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (sessionId) {
|
|
68
|
+
setSessionId(sessionId);
|
|
69
|
+
}
|
|
70
|
+
if (webUrl) {
|
|
71
|
+
setWebUrl(webUrl);
|
|
72
|
+
}
|
|
73
|
+
}, [sessionId, webUrl, setSessionId, setWebUrl]);
|
|
74
|
+
// Handle login flow
|
|
75
|
+
const handleLogin = async () => {
|
|
76
|
+
setShowAuthDialog(false);
|
|
77
|
+
setAuthState(AuthState.Authenticating);
|
|
78
|
+
addMessage({
|
|
79
|
+
type: "assistant",
|
|
80
|
+
content: "Opening browser for authentication...",
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const result = await loginCommand();
|
|
84
|
+
saveToken(result.token, result.expiresAt);
|
|
85
|
+
// Update ApiClient with the new token
|
|
86
|
+
if (apiClient) {
|
|
87
|
+
apiClient.setApiKey(result.token);
|
|
88
|
+
}
|
|
89
|
+
setAuthState(AuthState.Authenticated);
|
|
90
|
+
addMessage({
|
|
91
|
+
type: "assistant",
|
|
92
|
+
content: "Successfully logged in!",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
setAuthState(AuthState.Unauthenticated);
|
|
97
|
+
addMessage({
|
|
98
|
+
type: "error",
|
|
99
|
+
content: `Login failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
100
|
+
errorType: "error",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
// Handle task submission from input prompt
|
|
105
|
+
const handleSubmitTask = async (task) => {
|
|
106
|
+
const trimmedTask = task.trim();
|
|
107
|
+
// Handle Slash Commands
|
|
108
|
+
if (trimmedTask.startsWith("/")) {
|
|
109
|
+
const command = trimmedTask.toLowerCase();
|
|
110
|
+
if (command === "/clear") {
|
|
111
|
+
clearMessages();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (command === "/exit") {
|
|
115
|
+
exit();
|
|
116
|
+
onExit(true);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (command === "/help" || command === "/?") {
|
|
120
|
+
setShowHelp((prev) => !prev);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (command === "/login") {
|
|
124
|
+
if (authState === AuthState.Authenticated) {
|
|
125
|
+
addMessage({
|
|
126
|
+
type: "assistant",
|
|
127
|
+
content: "Already logged in. Use /logout first to log out.",
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setShowAuthDialog(true);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (command === "/logout") {
|
|
135
|
+
if (authState !== AuthState.Authenticated) {
|
|
136
|
+
addMessage({
|
|
137
|
+
type: "assistant",
|
|
138
|
+
content: "Not currently logged in.",
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
removeToken();
|
|
143
|
+
// Clear ApiClient token to prevent further API calls
|
|
144
|
+
if (apiClient) {
|
|
145
|
+
apiClient.clearApiKey();
|
|
146
|
+
}
|
|
147
|
+
setAuthState(AuthState.Unauthenticated);
|
|
148
|
+
addMessage({
|
|
149
|
+
type: "assistant",
|
|
150
|
+
content: "Successfully logged out!",
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (command === "/resume") {
|
|
155
|
+
if (!apiClient) {
|
|
156
|
+
addMessage({
|
|
157
|
+
type: "error",
|
|
158
|
+
content: "API client not available. Cannot fetch sessions.",
|
|
159
|
+
errorType: "error",
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
setShowSessionSelector(true);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (command === "/setup") {
|
|
167
|
+
addMessage({
|
|
168
|
+
type: "assistant",
|
|
169
|
+
content: "Running setup...",
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
const result = await setupCommand();
|
|
173
|
+
addMessage({
|
|
174
|
+
type: "assistant",
|
|
175
|
+
content: result.output,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
addMessage({
|
|
180
|
+
type: "error",
|
|
181
|
+
content: `Setup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
182
|
+
errorType: "error",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Check authentication before allowing task submission
|
|
189
|
+
if (authState !== AuthState.Authenticated) {
|
|
190
|
+
addMessage({
|
|
191
|
+
type: "error",
|
|
192
|
+
content: "Authentication required. Please login to continue.",
|
|
193
|
+
errorType: "warning",
|
|
194
|
+
});
|
|
195
|
+
setShowAuthDialog(true);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
setHasInputContent(false); // Reset content flag
|
|
199
|
+
setExitWarning(null);
|
|
200
|
+
// Don't add message here - let InteractiveApp handle it
|
|
201
|
+
// addMessage({
|
|
202
|
+
// type: "user",
|
|
203
|
+
// content: task,
|
|
204
|
+
// });
|
|
205
|
+
if (onSubmitTask) {
|
|
206
|
+
onSubmitTask(task);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
// Handle session selection
|
|
210
|
+
const handleSessionSelect = (session) => {
|
|
211
|
+
setShowSessionSelector(false);
|
|
212
|
+
addMessage({
|
|
213
|
+
type: "assistant",
|
|
214
|
+
content: `Switching to session: ${session.title || "Untitled"} (${session.id})`,
|
|
215
|
+
});
|
|
216
|
+
if (onResumeSession) {
|
|
217
|
+
onResumeSession(session);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const handleSessionSelectorCancel = () => {
|
|
221
|
+
setShowSessionSelector(false);
|
|
222
|
+
};
|
|
223
|
+
// Handle terminal resize
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const handleResize = () => {
|
|
226
|
+
setTerminalWidth(process.stdout.columns || 80);
|
|
227
|
+
setTerminalHeight(process.stdout.rows || 24);
|
|
228
|
+
};
|
|
229
|
+
process.stdout.on("resize", handleResize);
|
|
230
|
+
return () => {
|
|
231
|
+
process.stdout.off("resize", handleResize);
|
|
232
|
+
};
|
|
233
|
+
}, []);
|
|
234
|
+
// Keyboard shortcuts
|
|
235
|
+
useInput((input, key) => {
|
|
236
|
+
// ESC: Cancel current request (abort running operations)
|
|
237
|
+
if (key.escape && isAgentRunning) {
|
|
238
|
+
setShouldInterruptAgent(true);
|
|
239
|
+
addMessage({
|
|
240
|
+
type: "error",
|
|
241
|
+
content: "Request cancelled",
|
|
242
|
+
errorType: "info",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Ctrl+C: Cancel if running, or exit with warning
|
|
246
|
+
if (key.ctrl && input === "c") {
|
|
247
|
+
if (isAgentRunning) {
|
|
248
|
+
// Cancel the running request (same as Escape)
|
|
249
|
+
setShouldInterruptAgent(true);
|
|
250
|
+
addMessage({
|
|
251
|
+
type: "error",
|
|
252
|
+
content: "Request cancelled",
|
|
253
|
+
errorType: "info",
|
|
254
|
+
});
|
|
255
|
+
// Show exit warning
|
|
256
|
+
setExitWarning("Press Ctrl+C again to exit");
|
|
257
|
+
setTimeout(() => setExitWarning(null), 1500);
|
|
258
|
+
}
|
|
259
|
+
else if (exitWarning) {
|
|
260
|
+
// Second Ctrl+C within warning period - exit
|
|
261
|
+
exit();
|
|
262
|
+
onExit(true);
|
|
263
|
+
}
|
|
264
|
+
else if (showInput && hasInputContent) {
|
|
265
|
+
// Clear input and show warning
|
|
266
|
+
inputPromptRef.current?.clear();
|
|
267
|
+
setExitWarning("Press Ctrl+C again to exit");
|
|
268
|
+
setTimeout(() => setExitWarning(null), 1500);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
// Nothing running, no input - show warning first
|
|
272
|
+
setExitWarning("Press Ctrl+C again to exit");
|
|
273
|
+
setTimeout(() => setExitWarning(null), 1500);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Ctrl+D: Exit immediately
|
|
277
|
+
if (key.ctrl && input === "d") {
|
|
278
|
+
exit();
|
|
279
|
+
onExit(true);
|
|
280
|
+
}
|
|
281
|
+
// Ctrl+H: Toggle help (removing '?' check here as it conflicts with input)
|
|
282
|
+
if (key.ctrl && input === "h") {
|
|
283
|
+
setShowHelp((prev) => !prev);
|
|
284
|
+
}
|
|
285
|
+
// Ctrl+L: Clear screen (not messages, just terminal)
|
|
286
|
+
if (key.ctrl && input === "l") {
|
|
287
|
+
console.clear();
|
|
288
|
+
}
|
|
289
|
+
// Ctrl+O: Toggle all tool outputs
|
|
290
|
+
if (key.ctrl && input === "o") {
|
|
291
|
+
toggleAllToolOutputs();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
// Show initial user message if task was provided
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (config.task) {
|
|
297
|
+
addMessage({
|
|
298
|
+
type: "user",
|
|
299
|
+
content: config.task,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
303
|
+
return (React.createElement(Box, { flexDirection: "column", flexGrow: 0, flexShrink: 0, height: terminalHeight, overflow: "hidden", paddingX: 1 },
|
|
304
|
+
React.createElement(MessageList, { currentFolder: currentFolder, gitBranch: gitBranch, terminalWidth: terminalWidth }),
|
|
305
|
+
showHelp && React.createElement(HelpMenu, { isAuthenticated: authState === AuthState.Authenticated }),
|
|
306
|
+
showSessionSelector && apiClient && (React.createElement(SessionSelector, { apiClient: apiClient, onCancel: handleSessionSelectorCancel, onSelect: handleSessionSelect })),
|
|
307
|
+
showAuthDialog && (React.createElement(AuthDialog, { onLogin: handleLogin })),
|
|
308
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 0, flexShrink: 0 },
|
|
309
|
+
React.createElement(QueuedMessageDisplay, { messageQueue: queuedTasks }),
|
|
310
|
+
!showAuthDialog && React.createElement(AuthBanner, { authState: authState }),
|
|
311
|
+
showInput && !showSessionSelector && !showAuthDialog && (React.createElement(Box, { flexDirection: "column", marginTop: 0, width: "100%" },
|
|
312
|
+
exitWarning && (React.createElement(Box, { marginBottom: 0, paddingX: 1 },
|
|
313
|
+
React.createElement(Text, { color: "yellow" }, exitWarning))),
|
|
314
|
+
React.createElement(InputPrompt, { currentFolder: currentFolder, gitBranch: gitBranch, onHelpToggle: () => setShowHelp(prev => !prev), onInputChange: (val) => setHasInputContent(val.trim().length > 0), onSubmit: handleSubmitTask, placeholder: "Enter your task...", ref: inputPromptRef }))))));
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Root App component
|
|
318
|
+
* Note: SessionProvider should be provided by the parent component
|
|
319
|
+
*/
|
|
320
|
+
export const App = (props) => {
|
|
321
|
+
return React.createElement(AppContent, { ...props });
|
|
322
|
+
};
|