@astra-code/astra-ai 0.1.6 → 0.1.8
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/App.js +223 -58
- package/dist/app/App.js.map +1 -1
- package/dist/lib/backendClient.js +87 -17
- package/dist/lib/backendClient.js.map +1 -1
- package/dist/lib/voice.js +3 -0
- package/dist/lib/voice.js.map +1 -1
- package/package.json +1 -1
- package/src/app/App.tsx +277 -92
- package/src/lib/backendClient.ts +88 -17
- package/src/lib/voice.ts +2 -0
- package/src/types/events.ts +1 -0
package/dist/app/App.js
CHANGED
|
@@ -45,9 +45,27 @@ const centerLine = (text, width = WELCOME_WIDTH) => {
|
|
|
45
45
|
const FOUNDER_WELCOME = centerLine("Welcome to Astra from Astra CEO & Founder, Sean Donovan");
|
|
46
46
|
const HISTORY_SETTINGS_URL = "https://astra-web-builder.vercel.app/settings";
|
|
47
47
|
const VOICE_SILENCE_MS = Number(process.env.ASTRA_VOICE_SILENCE_MS ?? "3000");
|
|
48
|
-
const VOICE_MIN_CHARS = Number(process.env.ASTRA_VOICE_MIN_CHARS ?? "
|
|
48
|
+
const VOICE_MIN_CHARS = Number(process.env.ASTRA_VOICE_MIN_CHARS ?? "10");
|
|
49
49
|
const VOICE_DUPLICATE_WINDOW_MS = Number(process.env.ASTRA_VOICE_DUPLICATE_WINDOW_MS ?? "10000");
|
|
50
|
-
const VOICE_NOISE_WORDS = new Set([
|
|
50
|
+
const VOICE_NOISE_WORDS = new Set([
|
|
51
|
+
"you", "yes", "yeah", "yep", "ok", "okay", "uh", "um", "hmm", "hm",
|
|
52
|
+
"oh", "ah", "eh", "the", "a", "an", "and", "or", "is", "it", "in",
|
|
53
|
+
"to", "for", "of", "on", "at", "by", "with", "that", "this", "so",
|
|
54
|
+
"right", "like", "just", "hi", "hey", "bye", "no",
|
|
55
|
+
]);
|
|
56
|
+
// Known Whisper hallucination phrases triggered by silence/background noise
|
|
57
|
+
const VOICE_HALLUCINATION_PHRASES = [
|
|
58
|
+
"thank you for watching",
|
|
59
|
+
"thanks for watching",
|
|
60
|
+
"please subscribe",
|
|
61
|
+
"like and subscribe",
|
|
62
|
+
"click click click",
|
|
63
|
+
"hallelujah",
|
|
64
|
+
"subtitles by",
|
|
65
|
+
"transcribed by",
|
|
66
|
+
"www.",
|
|
67
|
+
"www.youtube",
|
|
68
|
+
];
|
|
51
69
|
const TOOL_SNIPPET_LINES = 6;
|
|
52
70
|
const NOISY_EVENT_TYPES = new Set([
|
|
53
71
|
"timing",
|
|
@@ -151,6 +169,14 @@ const extractAssistantText = (event) => {
|
|
|
151
169
|
};
|
|
152
170
|
const LABEL_WIDTH = 10;
|
|
153
171
|
const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
|
|
172
|
+
const formatTime = (ts) => {
|
|
173
|
+
const d = new Date(ts);
|
|
174
|
+
const raw = d.getHours();
|
|
175
|
+
const ampm = raw >= 12 ? "pm" : "am";
|
|
176
|
+
const h = raw % 12 === 0 ? 12 : raw % 12;
|
|
177
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
178
|
+
return `${h}:${m} ${ampm}`;
|
|
179
|
+
};
|
|
154
180
|
const MIN_DIVIDER = 64;
|
|
155
181
|
const MAX_DIVIDER = 120;
|
|
156
182
|
const styleForKind = (kind) => {
|
|
@@ -193,7 +219,13 @@ const normalizeAssistantText = (input) => {
|
|
|
193
219
|
// Remove control chars but preserve newlines/tabs.
|
|
194
220
|
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
|
|
195
221
|
.replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
|
|
196
|
-
|
|
222
|
+
// Add space after sentence-ending punctuation only when followed by an
|
|
223
|
+
// uppercase letter (sentence start). Using [A-Za-z] here would break
|
|
224
|
+
// file extensions like .css, .json, .jsx, .tsx — those always start with
|
|
225
|
+
// a lowercase letter.
|
|
226
|
+
.replace(/([.!?])([A-Z])/g, "$1 $2")
|
|
227
|
+
// For ! and ? followed by lowercase, also add a space (natural English).
|
|
228
|
+
.replace(/([!?])([a-z])/g, "$1 $2")
|
|
197
229
|
.replace(/([a-z])(\u2022)/g, "$1\n$2")
|
|
198
230
|
.replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
|
|
199
231
|
.replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
|
|
@@ -220,50 +252,85 @@ const normalizeAssistantText = (input) => {
|
|
|
220
252
|
}
|
|
221
253
|
return deduped.join("\n\n").trim();
|
|
222
254
|
};
|
|
255
|
+
const guessDevUrl = (command) => {
|
|
256
|
+
// Extract an explicit --port or -p value from the command.
|
|
257
|
+
const portMatch = command.match(/(?:--port|-p)\s+(\d+)/) ?? command.match(/(?:--port=|-p=)(\d+)/);
|
|
258
|
+
if (portMatch) {
|
|
259
|
+
return `http://localhost:${portMatch[1]}`;
|
|
260
|
+
}
|
|
261
|
+
// Default ports by framework.
|
|
262
|
+
if (/next/.test(command))
|
|
263
|
+
return "http://localhost:3000";
|
|
264
|
+
if (/vite|vue/.test(command))
|
|
265
|
+
return "http://localhost:5173";
|
|
266
|
+
if (/remix/.test(command))
|
|
267
|
+
return "http://localhost:3000";
|
|
268
|
+
if (/astro/.test(command))
|
|
269
|
+
return "http://localhost:4321";
|
|
270
|
+
if (/angular|ng\s+serve/.test(command))
|
|
271
|
+
return "http://localhost:4200";
|
|
272
|
+
if (/npm\s+run\s+dev|npm\s+start|npx\s+react-scripts/.test(command))
|
|
273
|
+
return "http://localhost:3000";
|
|
274
|
+
return null;
|
|
275
|
+
};
|
|
223
276
|
const summarizeToolResult = (toolName, data, success) => {
|
|
224
277
|
if (!success) {
|
|
225
|
-
return `${toolName} failed
|
|
278
|
+
return { summary: `${toolName} failed` };
|
|
226
279
|
}
|
|
227
280
|
const path = typeof data.path === "string" ? data.path : "";
|
|
228
281
|
const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
|
|
229
282
|
if (toolName === "view_file") {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
if (path)
|
|
234
|
-
|
|
235
|
-
|
|
283
|
+
const result = {
|
|
284
|
+
summary: totalLines !== null ? `Read ${totalLines} lines` : "Read file",
|
|
285
|
+
};
|
|
286
|
+
if (path)
|
|
287
|
+
result.path = path;
|
|
288
|
+
return result;
|
|
236
289
|
}
|
|
237
290
|
if (toolName === "list_directory") {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
return `Listed <${dir}>`;
|
|
291
|
+
return {
|
|
292
|
+
summary: totalLines !== null ? `Listed ${totalLines} entries` : "Listed directory",
|
|
293
|
+
path: path || ".",
|
|
294
|
+
};
|
|
243
295
|
}
|
|
244
296
|
if (toolName === "semantic_search") {
|
|
245
|
-
return "Searched codebase context";
|
|
297
|
+
return { summary: "Searched codebase context" };
|
|
246
298
|
}
|
|
247
299
|
if (toolName === "search_files") {
|
|
248
|
-
return
|
|
300
|
+
return {
|
|
301
|
+
summary: totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths",
|
|
302
|
+
};
|
|
249
303
|
}
|
|
250
|
-
return `${toolName} completed
|
|
304
|
+
return { summary: `${toolName} completed` };
|
|
251
305
|
};
|
|
252
306
|
const isLikelyVoiceNoise = (text) => {
|
|
253
307
|
const normalized = text.trim().toLowerCase();
|
|
254
|
-
if (!normalized)
|
|
308
|
+
if (!normalized)
|
|
255
309
|
return true;
|
|
256
|
-
|
|
257
|
-
|
|
310
|
+
// Strip leading punctuation/artifacts Whisper adds to silence ". " "- " etc.
|
|
311
|
+
const cleaned = normalized.replace(/^[\s.,!?…\-–—]+/, "").trim();
|
|
312
|
+
if (!cleaned || cleaned.length < VOICE_MIN_CHARS)
|
|
258
313
|
return true;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (tokens.length === 0) {
|
|
314
|
+
// Known Whisper hallucination phrases
|
|
315
|
+
if (VOICE_HALLUCINATION_PHRASES.some((p) => cleaned.includes(p)))
|
|
262
316
|
return true;
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
317
|
+
// Tokenize stripping punctuation so "okay." isn't treated as non-noise
|
|
318
|
+
const tokens = cleaned
|
|
319
|
+
.split(/\s+/)
|
|
320
|
+
.map((t) => t.replace(/[.,!?;:'"()\-…]+/g, ""))
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
if (tokens.length === 0)
|
|
323
|
+
return true;
|
|
324
|
+
const nonNoise = tokens.filter((t) => t.length > 1 && !VOICE_NOISE_WORDS.has(t));
|
|
325
|
+
if (nonNoise.length === 0)
|
|
266
326
|
return true;
|
|
327
|
+
// Repetition pattern: same short fragment repeated 3+ times = hallucination
|
|
328
|
+
// e.g. "Thank you. Thank you. Thank you for watching."
|
|
329
|
+
const wordList = cleaned.split(/\s+/).map((w) => w.replace(/[.,!?]/g, ""));
|
|
330
|
+
if (wordList.length >= 4) {
|
|
331
|
+
const uniqueWords = new Set(wordList.filter((w) => w.length > 2));
|
|
332
|
+
if (uniqueWords.size <= 2)
|
|
333
|
+
return true;
|
|
267
334
|
}
|
|
268
335
|
return false;
|
|
269
336
|
};
|
|
@@ -442,9 +509,17 @@ export const AstraApp = () => {
|
|
|
442
509
|
const welcomeLine = dividerWidth < 96 ? "Welcome to Astra" : FOUNDER_WELCOME;
|
|
443
510
|
const backend = useMemo(() => new BackendClient(), []);
|
|
444
511
|
const { exit } = useApp();
|
|
512
|
+
// Keep backend token fresh: when the client auto-refreshes on 401, persist and update state.
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
backend.setOnTokenRefreshed((refreshed) => {
|
|
515
|
+
saveSession(refreshed);
|
|
516
|
+
setUser(refreshed);
|
|
517
|
+
});
|
|
518
|
+
}, [backend]);
|
|
445
519
|
// In-session file cache: tracks files created/edited so subsequent requests
|
|
446
520
|
// include their latest content in workspaceFiles (VirtualFS stays up to date).
|
|
447
521
|
const localFileCache = useRef(new Map());
|
|
522
|
+
const abortRunRef = useRef(null);
|
|
448
523
|
const writeLocalFile = useCallback((relPath, content, language) => {
|
|
449
524
|
try {
|
|
450
525
|
const abs = join(workspaceRoot, relPath);
|
|
@@ -483,6 +558,7 @@ export const AstraApp = () => {
|
|
|
483
558
|
const [activeModel, setActiveModel] = useState(getDefaultModel());
|
|
484
559
|
const [creditsRemaining, setCreditsRemaining] = useState(null);
|
|
485
560
|
const [lastCreditCost, setLastCreditCost] = useState(null);
|
|
561
|
+
const [loopCtx, setLoopCtx] = useState(null);
|
|
486
562
|
const runtimeMode = getRuntimeMode();
|
|
487
563
|
const [prompt, setPrompt] = useState("");
|
|
488
564
|
const [thinking, setThinking] = useState(false);
|
|
@@ -494,6 +570,7 @@ export const AstraApp = () => {
|
|
|
494
570
|
const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState(null);
|
|
495
571
|
const [micSetupDevices, setMicSetupDevices] = useState(null);
|
|
496
572
|
const [toolFeedMode, setToolFeedMode] = useState("compact");
|
|
573
|
+
const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
|
|
497
574
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
498
575
|
const [historyMode, setHistoryMode] = useState("picker");
|
|
499
576
|
const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
|
|
@@ -508,11 +585,11 @@ export const AstraApp = () => {
|
|
|
508
585
|
const fileEditBuffersRef = useRef(new Map());
|
|
509
586
|
const isSuperAdmin = user?.role === "super_admin";
|
|
510
587
|
const pushMessage = useCallback((kind, text) => {
|
|
511
|
-
setMessages((prev) => [...prev, { kind, text }].slice(-300));
|
|
588
|
+
setMessages((prev) => [...prev, { kind, text, ts: Date.now() }].slice(-300));
|
|
512
589
|
}, []);
|
|
513
590
|
const pushToolCard = useCallback((card) => {
|
|
514
591
|
setMessages((prev) => {
|
|
515
|
-
const nextEntry = { kind: "tool", text: card.summary, card };
|
|
592
|
+
const nextEntry = { kind: "tool", text: card.summary, card, ts: Date.now() };
|
|
516
593
|
const last = prev[prev.length - 1];
|
|
517
594
|
if (last &&
|
|
518
595
|
last.kind === "tool" &&
|
|
@@ -806,6 +883,12 @@ export const AstraApp = () => {
|
|
|
806
883
|
exit();
|
|
807
884
|
return;
|
|
808
885
|
}
|
|
886
|
+
if (key.escape && thinking) {
|
|
887
|
+
abortRunRef.current?.abort();
|
|
888
|
+
pushMessage("system", "Cancelled.");
|
|
889
|
+
pushMessage("system", "");
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
809
892
|
if (historyOpen) {
|
|
810
893
|
if (key.escape) {
|
|
811
894
|
if (historyMode === "sessions") {
|
|
@@ -1122,16 +1205,18 @@ export const AstraApp = () => {
|
|
|
1122
1205
|
}
|
|
1123
1206
|
}
|
|
1124
1207
|
if (!isSuperAdmin && event.tool_name === "start_preview") {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1208
|
+
// Server mode returns preview_url (tunnel). Desktop mode returns a
|
|
1209
|
+
// plain message — try to guess the local URL from the command.
|
|
1210
|
+
const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
|
|
1211
|
+
const command = typeof d.command === "string" ? d.command : "";
|
|
1212
|
+
const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
|
|
1213
|
+
const displayUrl = tunnelUrl ?? localUrl;
|
|
1130
1214
|
pushToolCard({
|
|
1131
1215
|
kind: "preview",
|
|
1132
1216
|
toolName: "start_preview",
|
|
1133
1217
|
locality,
|
|
1134
|
-
summary:
|
|
1218
|
+
summary: "Dev server running",
|
|
1219
|
+
...(displayUrl ? { path: displayUrl } : {}),
|
|
1135
1220
|
});
|
|
1136
1221
|
return null;
|
|
1137
1222
|
}
|
|
@@ -1176,6 +1261,16 @@ export const AstraApp = () => {
|
|
|
1176
1261
|
pushMessage("system", "");
|
|
1177
1262
|
}
|
|
1178
1263
|
}
|
|
1264
|
+
if (event.type === "timing") {
|
|
1265
|
+
const ev = event;
|
|
1266
|
+
if (ev.phase === "llm_done") {
|
|
1267
|
+
const inTok = Number(ev.input_tokens ?? 0);
|
|
1268
|
+
const outTok = Number(ev.output_tokens ?? 0);
|
|
1269
|
+
if (inTok > 0 || outTok > 0)
|
|
1270
|
+
setLoopCtx({ in: inTok, out: outTok });
|
|
1271
|
+
}
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1179
1274
|
if (event.type === "continuation_check") {
|
|
1180
1275
|
const recommendation = typeof event.recommendation === "string" && event.recommendation
|
|
1181
1276
|
? event.recommendation
|
|
@@ -1213,12 +1308,13 @@ export const AstraApp = () => {
|
|
|
1213
1308
|
if (alreadyRepresented) {
|
|
1214
1309
|
return null;
|
|
1215
1310
|
}
|
|
1216
|
-
const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1311
|
+
const { summary, path: summaryPath } = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1217
1312
|
pushToolCard({
|
|
1218
1313
|
kind: event.success ? "success" : "error",
|
|
1219
1314
|
toolName,
|
|
1220
1315
|
locality: payload.local === true ? "LOCAL" : "REMOTE",
|
|
1221
|
-
summary: event.success ? summary : `${toolName} ${mark}
|
|
1316
|
+
summary: event.success ? summary : `${toolName} ${mark}`,
|
|
1317
|
+
...(summaryPath ? { path: summaryPath } : {}),
|
|
1222
1318
|
});
|
|
1223
1319
|
}
|
|
1224
1320
|
}
|
|
@@ -1230,12 +1326,13 @@ export const AstraApp = () => {
|
|
|
1230
1326
|
}
|
|
1231
1327
|
}
|
|
1232
1328
|
return null;
|
|
1233
|
-
}, [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]);
|
|
1329
|
+
}, [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]);
|
|
1234
1330
|
const sendPrompt = useCallback(async (rawPrompt) => {
|
|
1235
1331
|
const text = rawPrompt.trim();
|
|
1236
1332
|
if (!text || !user || thinking) {
|
|
1237
1333
|
return;
|
|
1238
1334
|
}
|
|
1335
|
+
setPrompt("");
|
|
1239
1336
|
// Mic onboarding: intercept when waiting for device selection.
|
|
1240
1337
|
if (micSetupDevices !== null) {
|
|
1241
1338
|
const idx = parseInt(text, 10);
|
|
@@ -1258,17 +1355,29 @@ export const AstraApp = () => {
|
|
|
1258
1355
|
return;
|
|
1259
1356
|
}
|
|
1260
1357
|
if (text === "/help") {
|
|
1261
|
-
pushMessage("system",
|
|
1358
|
+
pushMessage("system", isSuperAdmin
|
|
1359
|
+
? "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
|
|
1360
|
+
: "/new /history /voice /dictate on|off|status /settings /settings model <id> /logout /exit");
|
|
1262
1361
|
pushMessage("system", "");
|
|
1263
1362
|
return;
|
|
1264
1363
|
}
|
|
1265
1364
|
if (text === "/tools compact") {
|
|
1365
|
+
if (!isSuperAdmin) {
|
|
1366
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1367
|
+
pushMessage("system", "");
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1266
1370
|
setToolFeedMode("compact");
|
|
1267
1371
|
pushMessage("system", "Tool feed set to compact.");
|
|
1268
1372
|
pushMessage("system", "");
|
|
1269
1373
|
return;
|
|
1270
1374
|
}
|
|
1271
1375
|
if (text === "/tools expanded") {
|
|
1376
|
+
if (!isSuperAdmin) {
|
|
1377
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1378
|
+
pushMessage("system", "");
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1272
1381
|
setToolFeedMode("expanded");
|
|
1273
1382
|
pushMessage("system", "Tool feed set to expanded.");
|
|
1274
1383
|
pushMessage("system", "");
|
|
@@ -1367,6 +1476,9 @@ export const AstraApp = () => {
|
|
|
1367
1476
|
pushMessage("user", text);
|
|
1368
1477
|
setThinking(true);
|
|
1369
1478
|
setStreamingText("");
|
|
1479
|
+
setLoopCtx(null);
|
|
1480
|
+
const abortController = new AbortController();
|
|
1481
|
+
abortRunRef.current = abortController;
|
|
1370
1482
|
try {
|
|
1371
1483
|
// Scan the local workspace so the backend VirtualFS is populated.
|
|
1372
1484
|
// Merge in any files created/edited during this session so edits
|
|
@@ -1375,8 +1487,25 @@ export const AstraApp = () => {
|
|
|
1375
1487
|
const sessionFiles = Array.from(localFileCache.current.values());
|
|
1376
1488
|
const seenPaths = new Set(sessionFiles.map((f) => f.path));
|
|
1377
1489
|
const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
|
|
1378
|
-
|
|
1490
|
+
// `pendingText` is text received since the last committed block.
|
|
1491
|
+
// It gets flushed to the messages list whenever tool activity starts,
|
|
1492
|
+
// keeping text and tool cards in the exact order they were emitted.
|
|
1493
|
+
let pendingText = "";
|
|
1494
|
+
let allAssistantText = "";
|
|
1379
1495
|
let localActionConfirmed = false;
|
|
1496
|
+
const commitPending = (applyGuard = false) => {
|
|
1497
|
+
if (!pendingText.trim()) {
|
|
1498
|
+
pendingText = "";
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const clean = normalizeAssistantText(pendingText);
|
|
1502
|
+
const msg = applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
|
|
1503
|
+
? `Remote result (not yet confirmed as local filesystem change): ${clean}`
|
|
1504
|
+
: clean;
|
|
1505
|
+
pushMessage("assistant", msg);
|
|
1506
|
+
pendingText = "";
|
|
1507
|
+
setStreamingText("");
|
|
1508
|
+
};
|
|
1380
1509
|
for await (const event of backend.streamChat({
|
|
1381
1510
|
user,
|
|
1382
1511
|
sessionId: activeSessionId,
|
|
@@ -1384,7 +1513,8 @@ export const AstraApp = () => {
|
|
|
1384
1513
|
workspaceRoot,
|
|
1385
1514
|
workspaceTree,
|
|
1386
1515
|
workspaceFiles: mergedFiles,
|
|
1387
|
-
model: activeModel
|
|
1516
|
+
model: activeModel,
|
|
1517
|
+
signal: abortController.signal
|
|
1388
1518
|
})) {
|
|
1389
1519
|
if (event.type === "run_in_terminal") {
|
|
1390
1520
|
localActionConfirmed = true;
|
|
@@ -1398,30 +1528,37 @@ export const AstraApp = () => {
|
|
|
1398
1528
|
if (event.type === "done") {
|
|
1399
1529
|
break;
|
|
1400
1530
|
}
|
|
1531
|
+
// Flush any accumulated text before the first tool event so that text
|
|
1532
|
+
// appears above the tool cards that follow it — preserving order.
|
|
1533
|
+
if (event.type === "tool_start" || event.type === "run_in_terminal") {
|
|
1534
|
+
commitPending();
|
|
1535
|
+
}
|
|
1401
1536
|
const piece = await handleEvent(event, activeSessionId);
|
|
1402
1537
|
if (piece) {
|
|
1403
|
-
|
|
1404
|
-
|
|
1538
|
+
pendingText += piece;
|
|
1539
|
+
allAssistantText += piece;
|
|
1540
|
+
setStreamingText(normalizeAssistantText(pendingText));
|
|
1405
1541
|
}
|
|
1406
1542
|
}
|
|
1407
1543
|
setStreamingText("");
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
: cleanedAssistant;
|
|
1413
|
-
pushMessage("assistant", guardedAssistant);
|
|
1414
|
-
setChatMessages((prev) => [...prev, { role: "assistant", content: cleanedAssistant }]);
|
|
1544
|
+
commitPending(true);
|
|
1545
|
+
// Update conversation history for the backend with the full combined text.
|
|
1546
|
+
if (allAssistantText.trim()) {
|
|
1547
|
+
setChatMessages((prev) => [...prev, { role: "assistant", content: normalizeAssistantText(allAssistantText) }]);
|
|
1415
1548
|
}
|
|
1416
1549
|
else {
|
|
1417
|
-
setChatMessages((prev) => [...prev, { role: "assistant", content:
|
|
1550
|
+
setChatMessages((prev) => [...prev, { role: "assistant", content: allAssistantText }]);
|
|
1418
1551
|
}
|
|
1419
1552
|
pushMessage("system", "");
|
|
1420
1553
|
}
|
|
1421
1554
|
catch (error) {
|
|
1422
|
-
|
|
1555
|
+
// AbortError fires when user cancels — don't show as an error
|
|
1556
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
1557
|
+
pushMessage("error", `Error: ${error.message}`);
|
|
1558
|
+
}
|
|
1423
1559
|
}
|
|
1424
1560
|
finally {
|
|
1561
|
+
abortRunRef.current = null;
|
|
1425
1562
|
setThinking(false);
|
|
1426
1563
|
}
|
|
1427
1564
|
}, [
|
|
@@ -1447,6 +1584,17 @@ export const AstraApp = () => {
|
|
|
1447
1584
|
voiceWaitingForSilence,
|
|
1448
1585
|
workspaceRoot
|
|
1449
1586
|
]);
|
|
1587
|
+
const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
|
|
1588
|
+
useEffect(() => {
|
|
1589
|
+
if (!thinking) {
|
|
1590
|
+
setThinkingColorIdx(0);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const interval = setInterval(() => {
|
|
1594
|
+
setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
|
|
1595
|
+
}, 120);
|
|
1596
|
+
return () => clearInterval(interval);
|
|
1597
|
+
}, [thinking]);
|
|
1450
1598
|
useEffect(() => {
|
|
1451
1599
|
if (!voiceQueuedPrompt || !user || thinking) {
|
|
1452
1600
|
return;
|
|
@@ -1509,7 +1657,9 @@ export const AstraApp = () => {
|
|
|
1509
1657
|
return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { color: active ? "#dce9ff" : "#7a9bba", children: [active ? "❯ " : " ", (row.title || "Untitled").slice(0, 58).padEnd(60, " ")] }), _jsxs(Text, { color: "#5a7a9a", children: [String(row.total_messages ?? 0).padStart(3, " "), " msgs \u00B7 ", formatSessionDate(row.updated_at)] })] }, row.id));
|
|
1510
1658
|
}) })), _jsx(Text, { color: "#2a3a50", children: divider }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { color: "#5a7a9a", children: ["Page ", historyPage + 1, " / ", historyPageCount] }), _jsxs(Text, { color: "#5a7a9a", children: ["Selected: ", selected ? selected.id : "--"] })] })] }))] }));
|
|
1511
1659
|
}
|
|
1512
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: brand }), _jsx(Text, { color: "#8ea1bd", children: welcomeLine }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#7a9bba", children: `mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${creditsRemaining ?? "--"}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}
|
|
1660
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: brand }), _jsx(Text, { color: "#8ea1bd", children: welcomeLine }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#7a9bba", children: `mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${creditsRemaining ?? "--"}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}${loopCtx
|
|
1661
|
+
? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
|
|
1662
|
+
: ""}` }), _jsx(Text, { color: "#6c88a8", children: `scope ${workspaceRoot.length > HEADER_PATH_MAX ? `…${workspaceRoot.slice(-(HEADER_PATH_MAX - 1))}` : workspaceRoot} · voice ${voiceEnabled
|
|
1513
1663
|
? voicePreparing
|
|
1514
1664
|
? "on/preparing"
|
|
1515
1665
|
: voiceListening
|
|
@@ -1517,7 +1667,7 @@ export const AstraApp = () => {
|
|
|
1517
1667
|
? "on/waiting"
|
|
1518
1668
|
: "on/listening"
|
|
1519
1669
|
: "on/standby"
|
|
1520
|
-
: "off"}` }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#3a5068", children: "/help /new /history /voice /dictate on|off|status" }), _jsx(Text, { color: "#3a5068", children: "/tools compact|expanded /settings /logout /exit" }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [messages.map((message, index) => {
|
|
1670
|
+
: "off"}` }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#3a5068", children: "/help /new /history /voice /dictate on|off|status" }), _jsx(Text, { color: "#3a5068", children: isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit" }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [messages.map((message, index) => {
|
|
1521
1671
|
const prev = index > 0 ? messages[index - 1] : null;
|
|
1522
1672
|
const style = styleForKind(message.kind);
|
|
1523
1673
|
const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
|
|
@@ -1527,11 +1677,21 @@ export const AstraApp = () => {
|
|
|
1527
1677
|
message.kind !== "system" &&
|
|
1528
1678
|
prev?.kind !== message.kind &&
|
|
1529
1679
|
(message.kind === "assistant" || message.kind === "tool");
|
|
1680
|
+
// Show a subtle turn separator before each assistant response that
|
|
1681
|
+
// follows a tool block — makes it easy to see where one turn ends.
|
|
1682
|
+
const needsTurnSeparator = message.kind === "assistant" &&
|
|
1683
|
+
Boolean(prev) &&
|
|
1684
|
+
prev?.kind === "tool";
|
|
1530
1685
|
if (isSpacing) {
|
|
1531
1686
|
return _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: " " }) }, `${index}-spacer`);
|
|
1532
1687
|
}
|
|
1533
1688
|
if (message.kind === "tool" && message.card && !isSuperAdmin) {
|
|
1534
1689
|
const card = message.card;
|
|
1690
|
+
// In compact mode, hide "start" spinner cards — they create noise
|
|
1691
|
+
// (one per tool call) without adding signal after the run completes.
|
|
1692
|
+
if (toolFeedMode === "compact" && card.kind === "start") {
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1535
1695
|
const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
|
|
1536
1696
|
const icon = card.kind === "error"
|
|
1537
1697
|
? "✕"
|
|
@@ -1559,11 +1719,16 @@ export const AstraApp = () => {
|
|
|
1559
1719
|
: card.kind === "preview"
|
|
1560
1720
|
? "#9ad5ff"
|
|
1561
1721
|
: "#9bc5ff";
|
|
1562
|
-
|
|
1722
|
+
// Timestamps fade with age: bright for recent, dim for older
|
|
1723
|
+
const tsAge = message.ts ? Date.now() - message.ts : Infinity;
|
|
1724
|
+
const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
|
|
1725
|
+
return (_jsxs(React.Fragment, { children: [needsTurnSeparator ? _jsx(Text, { color: "#1e2e40", children: "─".repeat(48) }) : needsGroupGap ? _jsx(Text, { children: " " }) : null, _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, children: paddedLabel }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: accent, children: [icon, " ", card.summary, card.path ? _jsxs(Text, { color: "#ffffff", children: [" ", card.path] }) : null, card.count && card.count > 1 ? _jsxs(Text, { color: "#9cb8d8", children: [" (x", card.count, ")"] }) : null, _jsxs(Text, { color: "#5a7a9a", children: [" \u00B7 ", localityLabel] })] }), toolFeedMode === "expanded" ? (_jsxs(_Fragment, { children: [card.path ? _jsxs(Text, { color: "#6c88a8", children: ["path: ", card.path] }) : null, (card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (_jsx(Text, { color: "#8ea1bd", children: line }, `${index}-snippet-${idx}`)))] })) : null] }), message.ts ? _jsxs(Text, { color: tsColor, children: [" ", formatTime(message.ts)] }) : null] })] })] }, `${index}-${message.kind}`));
|
|
1563
1726
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1727
|
+
const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
|
|
1728
|
+
const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
|
|
1729
|
+
const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
|
|
1730
|
+
return (_jsxs(React.Fragment, { children: [needsTurnSeparator ? _jsx(Text, { color: "#1e2e40", children: "─".repeat(48) }) : needsGroupGap ? _jsx(Text, { children: " " }) : null, _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, bold: style.bold, children: paddedLabel }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [message.kind === "assistant" ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", children: renderMarkdownContent(message.text, style.textColor, `assistant-${index}`) })) : (_jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: style.textColor, bold: style.bold && message.kind === "error", children: message.text }) })), showTs ? _jsxs(Text, { color: tsColor2, children: [" ", formatTime(message.ts)] }) : null] })] })] }, `${index}-${message.kind}`));
|
|
1731
|
+
}), streamingText ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: styleForKind("assistant").label.padEnd(LABEL_WIDTH, " ") }), _jsx(Box, { flexDirection: "column", children: renderMarkdownContent(streamingText, "#dce9ff", "streaming") })] })) : null] }), _jsx(Text, { color: "#2a3a50", children: divider }), thinking && !streamingText ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#7aa2ff", children: "◆ astra".padEnd(LABEL_WIDTH, " ") }), _jsxs(Text, { color: (THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff"), children: [_jsx(Spinner, { type: "dots2" }), _jsx(Text, { color: (THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff"), children: " Working..." }), _jsx(Text, { color: "#2a4060", children: " esc to cancel" })] })] })) : null, voiceEnabled && !thinking ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#9ad5ff", children: "🎤 voice".padEnd(LABEL_WIDTH, " ") }), voicePreparing ? (_jsx(Text, { color: "#f4d58a", children: "\uD83D\uDFE1 preparing microphone..." })) : voiceListening && !voiceWaitingForSilence ? (_jsx(Text, { color: "#9de3b4", children: "\uD83D\uDFE2 listening now - speak clearly" })) : voiceWaitingForSilence ? (_jsx(Text, { color: "#b7c4d8", children: "\u23F3 speech detected - waiting for silence to send" })) : (_jsx(Text, { color: "#6f8199", children: "\u26AA voice armed - preparing next listen window" }))] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: "\u276F " }), _jsx(TextInput, { value: prompt, onSubmit: (value) => {
|
|
1567
1732
|
void sendPrompt(value);
|
|
1568
1733
|
}, onChange: (value) => {
|
|
1569
1734
|
if (!voiceListening) {
|