@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/src/app/App.tsx
CHANGED
|
@@ -25,6 +25,7 @@ type UiMessage = {
|
|
|
25
25
|
kind: "system" | "user" | "assistant" | "tool" | "error";
|
|
26
26
|
text: string;
|
|
27
27
|
card?: ToolCard;
|
|
28
|
+
ts?: number;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
type HistoryMode = "picker" | "sessions";
|
|
@@ -77,9 +78,29 @@ const FOUNDER_WELCOME = centerLine("Welcome to Astra from Astra CEO & Founder, S
|
|
|
77
78
|
const HISTORY_SETTINGS_URL = "https://astra-web-builder.vercel.app/settings";
|
|
78
79
|
const VOICE_SILENCE_MS = Number(process.env.ASTRA_VOICE_SILENCE_MS ?? "3000");
|
|
79
80
|
|
|
80
|
-
const VOICE_MIN_CHARS = Number(process.env.ASTRA_VOICE_MIN_CHARS ?? "
|
|
81
|
+
const VOICE_MIN_CHARS = Number(process.env.ASTRA_VOICE_MIN_CHARS ?? "10");
|
|
81
82
|
const VOICE_DUPLICATE_WINDOW_MS = Number(process.env.ASTRA_VOICE_DUPLICATE_WINDOW_MS ?? "10000");
|
|
82
|
-
const VOICE_NOISE_WORDS = new Set([
|
|
83
|
+
const VOICE_NOISE_WORDS = new Set([
|
|
84
|
+
"you", "yes", "yeah", "yep", "ok", "okay", "uh", "um", "hmm", "hm",
|
|
85
|
+
"oh", "ah", "eh", "the", "a", "an", "and", "or", "is", "it", "in",
|
|
86
|
+
"to", "for", "of", "on", "at", "by", "with", "that", "this", "so",
|
|
87
|
+
"right", "like", "just", "hi", "hey", "bye", "no",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// Known Whisper hallucination phrases triggered by silence/background noise
|
|
91
|
+
const VOICE_HALLUCINATION_PHRASES = [
|
|
92
|
+
"thank you for watching",
|
|
93
|
+
"thanks for watching",
|
|
94
|
+
"please subscribe",
|
|
95
|
+
"like and subscribe",
|
|
96
|
+
"click click click",
|
|
97
|
+
"hallelujah",
|
|
98
|
+
"subtitles by",
|
|
99
|
+
"transcribed by",
|
|
100
|
+
"www.",
|
|
101
|
+
"www.youtube",
|
|
102
|
+
];
|
|
103
|
+
|
|
83
104
|
const TOOL_SNIPPET_LINES = 6;
|
|
84
105
|
const NOISY_EVENT_TYPES = new Set([
|
|
85
106
|
"timing",
|
|
@@ -198,6 +219,15 @@ const extractAssistantText = (event: AgentEvent): string | null => {
|
|
|
198
219
|
|
|
199
220
|
const LABEL_WIDTH = 10;
|
|
200
221
|
const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
|
|
222
|
+
|
|
223
|
+
const formatTime = (ts: number): string => {
|
|
224
|
+
const d = new Date(ts);
|
|
225
|
+
const raw = d.getHours();
|
|
226
|
+
const ampm = raw >= 12 ? "pm" : "am";
|
|
227
|
+
const h = raw % 12 === 0 ? 12 : raw % 12;
|
|
228
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
229
|
+
return `${h}:${m} ${ampm}`;
|
|
230
|
+
};
|
|
201
231
|
const MIN_DIVIDER = 64;
|
|
202
232
|
const MAX_DIVIDER = 120;
|
|
203
233
|
|
|
@@ -248,7 +278,13 @@ const normalizeAssistantText = (input: string): string => {
|
|
|
248
278
|
// Remove control chars but preserve newlines/tabs.
|
|
249
279
|
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
|
|
250
280
|
.replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
|
|
251
|
-
|
|
281
|
+
// Add space after sentence-ending punctuation only when followed by an
|
|
282
|
+
// uppercase letter (sentence start). Using [A-Za-z] here would break
|
|
283
|
+
// file extensions like .css, .json, .jsx, .tsx — those always start with
|
|
284
|
+
// a lowercase letter.
|
|
285
|
+
.replace(/([.!?])([A-Z])/g, "$1 $2")
|
|
286
|
+
// For ! and ? followed by lowercase, also add a space (natural English).
|
|
287
|
+
.replace(/([!?])([a-z])/g, "$1 $2")
|
|
252
288
|
.replace(/([a-z])(\u2022)/g, "$1\n$2")
|
|
253
289
|
.replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
|
|
254
290
|
.replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
|
|
@@ -277,52 +313,85 @@ const normalizeAssistantText = (input: string): string => {
|
|
|
277
313
|
return deduped.join("\n\n").trim();
|
|
278
314
|
};
|
|
279
315
|
|
|
280
|
-
const
|
|
316
|
+
const guessDevUrl = (command: string): string | null => {
|
|
317
|
+
// Extract an explicit --port or -p value from the command.
|
|
318
|
+
const portMatch = command.match(/(?:--port|-p)\s+(\d+)/) ?? command.match(/(?:--port=|-p=)(\d+)/);
|
|
319
|
+
if (portMatch) {
|
|
320
|
+
return `http://localhost:${portMatch[1]}`;
|
|
321
|
+
}
|
|
322
|
+
// Default ports by framework.
|
|
323
|
+
if (/next/.test(command)) return "http://localhost:3000";
|
|
324
|
+
if (/vite|vue/.test(command)) return "http://localhost:5173";
|
|
325
|
+
if (/remix/.test(command)) return "http://localhost:3000";
|
|
326
|
+
if (/astro/.test(command)) return "http://localhost:4321";
|
|
327
|
+
if (/angular|ng\s+serve/.test(command)) return "http://localhost:4200";
|
|
328
|
+
if (/npm\s+run\s+dev|npm\s+start|npx\s+react-scripts/.test(command)) return "http://localhost:3000";
|
|
329
|
+
return null;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const summarizeToolResult = (
|
|
333
|
+
toolName: string,
|
|
334
|
+
data: Record<string, unknown>,
|
|
335
|
+
success: boolean
|
|
336
|
+
): {summary: string; path?: string} => {
|
|
281
337
|
if (!success) {
|
|
282
|
-
return `${toolName} failed
|
|
338
|
+
return {summary: `${toolName} failed`};
|
|
283
339
|
}
|
|
284
340
|
const path = typeof data.path === "string" ? data.path : "";
|
|
285
341
|
const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
|
|
286
342
|
if (toolName === "view_file") {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
if (path)
|
|
291
|
-
|
|
292
|
-
}
|
|
343
|
+
const result: {summary: string; path?: string} = {
|
|
344
|
+
summary: totalLines !== null ? `Read ${totalLines} lines` : "Read file",
|
|
345
|
+
};
|
|
346
|
+
if (path) result.path = path;
|
|
347
|
+
return result;
|
|
293
348
|
}
|
|
294
349
|
if (toolName === "list_directory") {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
return `Listed <${dir}>`;
|
|
350
|
+
return {
|
|
351
|
+
summary: totalLines !== null ? `Listed ${totalLines} entries` : "Listed directory",
|
|
352
|
+
path: path || ".",
|
|
353
|
+
};
|
|
300
354
|
}
|
|
301
355
|
if (toolName === "semantic_search") {
|
|
302
|
-
return "Searched codebase context";
|
|
356
|
+
return {summary: "Searched codebase context"};
|
|
303
357
|
}
|
|
304
358
|
if (toolName === "search_files") {
|
|
305
|
-
return
|
|
359
|
+
return {
|
|
360
|
+
summary: totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths",
|
|
361
|
+
};
|
|
306
362
|
}
|
|
307
|
-
return `${toolName} completed
|
|
363
|
+
return {summary: `${toolName} completed`};
|
|
308
364
|
};
|
|
309
365
|
|
|
310
366
|
const isLikelyVoiceNoise = (text: string): boolean => {
|
|
311
367
|
const normalized = text.trim().toLowerCase();
|
|
312
|
-
if (!normalized)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
368
|
+
if (!normalized) return true;
|
|
369
|
+
|
|
370
|
+
// Strip leading punctuation/artifacts Whisper adds to silence ". " "- " etc.
|
|
371
|
+
const cleaned = normalized.replace(/^[\s.,!?…\-–—]+/, "").trim();
|
|
372
|
+
if (!cleaned || cleaned.length < VOICE_MIN_CHARS) return true;
|
|
373
|
+
|
|
374
|
+
// Known Whisper hallucination phrases
|
|
375
|
+
if (VOICE_HALLUCINATION_PHRASES.some((p) => cleaned.includes(p))) return true;
|
|
376
|
+
|
|
377
|
+
// Tokenize stripping punctuation so "okay." isn't treated as non-noise
|
|
378
|
+
const tokens = cleaned
|
|
379
|
+
.split(/\s+/)
|
|
380
|
+
.map((t) => t.replace(/[.,!?;:'"()\-…]+/g, ""))
|
|
381
|
+
.filter(Boolean);
|
|
382
|
+
if (tokens.length === 0) return true;
|
|
383
|
+
|
|
384
|
+
const nonNoise = tokens.filter((t) => t.length > 1 && !VOICE_NOISE_WORDS.has(t));
|
|
385
|
+
if (nonNoise.length === 0) return true;
|
|
386
|
+
|
|
387
|
+
// Repetition pattern: same short fragment repeated 3+ times = hallucination
|
|
388
|
+
// e.g. "Thank you. Thank you. Thank you for watching."
|
|
389
|
+
const wordList = cleaned.split(/\s+/).map((w) => w.replace(/[.,!?]/g, ""));
|
|
390
|
+
if (wordList.length >= 4) {
|
|
391
|
+
const uniqueWords = new Set(wordList.filter((w) => w.length > 2));
|
|
392
|
+
if (uniqueWords.size <= 2) return true;
|
|
325
393
|
}
|
|
394
|
+
|
|
326
395
|
return false;
|
|
327
396
|
};
|
|
328
397
|
|
|
@@ -602,9 +671,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
602
671
|
const backend = useMemo(() => new BackendClient(), []);
|
|
603
672
|
const {exit} = useApp();
|
|
604
673
|
|
|
674
|
+
// Keep backend token fresh: when the client auto-refreshes on 401, persist and update state.
|
|
675
|
+
useEffect(() => {
|
|
676
|
+
backend.setOnTokenRefreshed((refreshed) => {
|
|
677
|
+
saveSession(refreshed);
|
|
678
|
+
setUser(refreshed);
|
|
679
|
+
});
|
|
680
|
+
}, [backend]);
|
|
681
|
+
|
|
605
682
|
// In-session file cache: tracks files created/edited so subsequent requests
|
|
606
683
|
// include their latest content in workspaceFiles (VirtualFS stays up to date).
|
|
607
684
|
const localFileCache = useRef<Map<string, WorkspaceFile>>(new Map());
|
|
685
|
+
const abortRunRef = useRef<AbortController | null>(null);
|
|
608
686
|
|
|
609
687
|
const writeLocalFile = useCallback(
|
|
610
688
|
(relPath: string, content: string, language: string) => {
|
|
@@ -651,6 +729,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
651
729
|
const [activeModel, setActiveModel] = useState(getDefaultModel());
|
|
652
730
|
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
|
653
731
|
const [lastCreditCost, setLastCreditCost] = useState<number | null>(null);
|
|
732
|
+
const [loopCtx, setLoopCtx] = useState<{in: number; out: number} | null>(null);
|
|
654
733
|
const runtimeMode = getRuntimeMode();
|
|
655
734
|
const [prompt, setPrompt] = useState("");
|
|
656
735
|
const [thinking, setThinking] = useState(false);
|
|
@@ -662,6 +741,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
662
741
|
const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
|
|
663
742
|
const [micSetupDevices, setMicSetupDevices] = useState<Array<{index: number; name: string}> | null>(null);
|
|
664
743
|
const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
|
|
744
|
+
const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
|
|
665
745
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
666
746
|
const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
|
|
667
747
|
const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
|
|
@@ -677,12 +757,12 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
677
757
|
const isSuperAdmin = user?.role === "super_admin";
|
|
678
758
|
|
|
679
759
|
const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
|
|
680
|
-
setMessages((prev) => [...prev, {kind, text}].slice(-300));
|
|
760
|
+
setMessages((prev) => [...prev, {kind, text, ts: Date.now()}].slice(-300));
|
|
681
761
|
}, []);
|
|
682
762
|
|
|
683
763
|
const pushToolCard = useCallback((card: ToolCard) => {
|
|
684
764
|
setMessages((prev) => {
|
|
685
|
-
const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
|
|
765
|
+
const nextEntry = {kind: "tool", text: card.summary, card, ts: Date.now()} satisfies UiMessage;
|
|
686
766
|
const last = prev[prev.length - 1];
|
|
687
767
|
if (
|
|
688
768
|
last &&
|
|
@@ -991,6 +1071,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
991
1071
|
return;
|
|
992
1072
|
}
|
|
993
1073
|
|
|
1074
|
+
if (key.escape && thinking) {
|
|
1075
|
+
abortRunRef.current?.abort();
|
|
1076
|
+
pushMessage("system", "Cancelled.");
|
|
1077
|
+
pushMessage("system", "");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
994
1081
|
if (historyOpen) {
|
|
995
1082
|
if (key.escape) {
|
|
996
1083
|
if (historyMode === "sessions") {
|
|
@@ -1323,17 +1410,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1323
1410
|
}
|
|
1324
1411
|
|
|
1325
1412
|
if (!isSuperAdmin && event.tool_name === "start_preview") {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1413
|
+
// Server mode returns preview_url (tunnel). Desktop mode returns a
|
|
1414
|
+
// plain message — try to guess the local URL from the command.
|
|
1415
|
+
const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
|
|
1416
|
+
const command = typeof d.command === "string" ? d.command : "";
|
|
1417
|
+
const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
|
|
1418
|
+
const displayUrl = tunnelUrl ?? localUrl;
|
|
1332
1419
|
pushToolCard({
|
|
1333
1420
|
kind: "preview",
|
|
1334
1421
|
toolName: "start_preview",
|
|
1335
1422
|
locality,
|
|
1336
|
-
summary:
|
|
1423
|
+
summary: "Dev server running",
|
|
1424
|
+
...(displayUrl ? {path: displayUrl} : {}),
|
|
1337
1425
|
});
|
|
1338
1426
|
return null;
|
|
1339
1427
|
}
|
|
@@ -1383,6 +1471,16 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1383
1471
|
}
|
|
1384
1472
|
}
|
|
1385
1473
|
|
|
1474
|
+
if (event.type === "timing") {
|
|
1475
|
+
const ev = event as Record<string, unknown>;
|
|
1476
|
+
if (ev.phase === "llm_done") {
|
|
1477
|
+
const inTok = Number(ev.input_tokens ?? 0);
|
|
1478
|
+
const outTok = Number(ev.output_tokens ?? 0);
|
|
1479
|
+
if (inTok > 0 || outTok > 0) setLoopCtx({in: inTok, out: outTok});
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1386
1484
|
if (event.type === "continuation_check") {
|
|
1387
1485
|
const recommendation =
|
|
1388
1486
|
typeof event.recommendation === "string" && event.recommendation
|
|
@@ -1426,12 +1524,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1426
1524
|
if (alreadyRepresented) {
|
|
1427
1525
|
return null;
|
|
1428
1526
|
}
|
|
1429
|
-
const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1527
|
+
const {summary, path: summaryPath} = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1430
1528
|
pushToolCard({
|
|
1431
1529
|
kind: event.success ? "success" : "error",
|
|
1432
1530
|
toolName,
|
|
1433
1531
|
locality: payload.local === true ? "LOCAL" : "REMOTE",
|
|
1434
|
-
summary: event.success ? summary : `${toolName} ${mark}
|
|
1532
|
+
summary: event.success ? summary : `${toolName} ${mark}`,
|
|
1533
|
+
...(summaryPath ? {path: summaryPath} : {}),
|
|
1435
1534
|
});
|
|
1436
1535
|
}
|
|
1437
1536
|
}
|
|
@@ -1443,7 +1542,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1443
1542
|
}
|
|
1444
1543
|
return null;
|
|
1445
1544
|
},
|
|
1446
|
-
[backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
|
|
1545
|
+
[backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]
|
|
1447
1546
|
);
|
|
1448
1547
|
|
|
1449
1548
|
const sendPrompt = useCallback(
|
|
@@ -1452,6 +1551,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1452
1551
|
if (!text || !user || thinking) {
|
|
1453
1552
|
return;
|
|
1454
1553
|
}
|
|
1554
|
+
setPrompt("");
|
|
1455
1555
|
|
|
1456
1556
|
// Mic onboarding: intercept when waiting for device selection.
|
|
1457
1557
|
if (micSetupDevices !== null) {
|
|
@@ -1478,18 +1578,30 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1478
1578
|
if (text === "/help") {
|
|
1479
1579
|
pushMessage(
|
|
1480
1580
|
"system",
|
|
1481
|
-
|
|
1581
|
+
isSuperAdmin
|
|
1582
|
+
? "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
|
|
1583
|
+
: "/new /history /voice /dictate on|off|status /settings /settings model <id> /logout /exit"
|
|
1482
1584
|
);
|
|
1483
1585
|
pushMessage("system", "");
|
|
1484
1586
|
return;
|
|
1485
1587
|
}
|
|
1486
1588
|
if (text === "/tools compact") {
|
|
1589
|
+
if (!isSuperAdmin) {
|
|
1590
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1591
|
+
pushMessage("system", "");
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1487
1594
|
setToolFeedMode("compact");
|
|
1488
1595
|
pushMessage("system", "Tool feed set to compact.");
|
|
1489
1596
|
pushMessage("system", "");
|
|
1490
1597
|
return;
|
|
1491
1598
|
}
|
|
1492
1599
|
if (text === "/tools expanded") {
|
|
1600
|
+
if (!isSuperAdmin) {
|
|
1601
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1602
|
+
pushMessage("system", "");
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1493
1605
|
setToolFeedMode("expanded");
|
|
1494
1606
|
pushMessage("system", "Tool feed set to expanded.");
|
|
1495
1607
|
pushMessage("system", "");
|
|
@@ -1605,6 +1717,10 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1605
1717
|
pushMessage("user", text);
|
|
1606
1718
|
setThinking(true);
|
|
1607
1719
|
setStreamingText("");
|
|
1720
|
+
setLoopCtx(null);
|
|
1721
|
+
|
|
1722
|
+
const abortController = new AbortController();
|
|
1723
|
+
abortRunRef.current = abortController;
|
|
1608
1724
|
|
|
1609
1725
|
try {
|
|
1610
1726
|
// Scan the local workspace so the backend VirtualFS is populated.
|
|
@@ -1615,8 +1731,28 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1615
1731
|
const seenPaths = new Set(sessionFiles.map((f) => f.path));
|
|
1616
1732
|
const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
|
|
1617
1733
|
|
|
1618
|
-
|
|
1734
|
+
// `pendingText` is text received since the last committed block.
|
|
1735
|
+
// It gets flushed to the messages list whenever tool activity starts,
|
|
1736
|
+
// keeping text and tool cards in the exact order they were emitted.
|
|
1737
|
+
let pendingText = "";
|
|
1738
|
+
let allAssistantText = "";
|
|
1619
1739
|
let localActionConfirmed = false;
|
|
1740
|
+
|
|
1741
|
+
const commitPending = (applyGuard = false) => {
|
|
1742
|
+
if (!pendingText.trim()) {
|
|
1743
|
+
pendingText = "";
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const clean = normalizeAssistantText(pendingText);
|
|
1747
|
+
const msg =
|
|
1748
|
+
applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
|
|
1749
|
+
? `Remote result (not yet confirmed as local filesystem change): ${clean}`
|
|
1750
|
+
: clean;
|
|
1751
|
+
pushMessage("assistant", msg);
|
|
1752
|
+
pendingText = "";
|
|
1753
|
+
setStreamingText("");
|
|
1754
|
+
};
|
|
1755
|
+
|
|
1620
1756
|
for await (const event of backend.streamChat({
|
|
1621
1757
|
user,
|
|
1622
1758
|
sessionId: activeSessionId,
|
|
@@ -1624,7 +1760,8 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1624
1760
|
workspaceRoot,
|
|
1625
1761
|
workspaceTree,
|
|
1626
1762
|
workspaceFiles: mergedFiles,
|
|
1627
|
-
model: activeModel
|
|
1763
|
+
model: activeModel,
|
|
1764
|
+
signal: abortController.signal
|
|
1628
1765
|
})) {
|
|
1629
1766
|
if (event.type === "run_in_terminal") {
|
|
1630
1767
|
localActionConfirmed = true;
|
|
@@ -1638,29 +1775,35 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1638
1775
|
if (event.type === "done") {
|
|
1639
1776
|
break;
|
|
1640
1777
|
}
|
|
1778
|
+
// Flush any accumulated text before the first tool event so that text
|
|
1779
|
+
// appears above the tool cards that follow it — preserving order.
|
|
1780
|
+
if (event.type === "tool_start" || event.type === "run_in_terminal") {
|
|
1781
|
+
commitPending();
|
|
1782
|
+
}
|
|
1641
1783
|
const piece = await handleEvent(event, activeSessionId);
|
|
1642
1784
|
if (piece) {
|
|
1643
|
-
|
|
1644
|
-
|
|
1785
|
+
pendingText += piece;
|
|
1786
|
+
allAssistantText += piece;
|
|
1787
|
+
setStreamingText(normalizeAssistantText(pendingText));
|
|
1645
1788
|
}
|
|
1646
1789
|
}
|
|
1647
1790
|
|
|
1648
1791
|
setStreamingText("");
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
|
|
1654
|
-
: cleanedAssistant;
|
|
1655
|
-
pushMessage("assistant", guardedAssistant);
|
|
1656
|
-
setChatMessages((prev) => [...prev, {role: "assistant", content: cleanedAssistant}]);
|
|
1792
|
+
commitPending(true);
|
|
1793
|
+
// Update conversation history for the backend with the full combined text.
|
|
1794
|
+
if (allAssistantText.trim()) {
|
|
1795
|
+
setChatMessages((prev) => [...prev, {role: "assistant", content: normalizeAssistantText(allAssistantText)}]);
|
|
1657
1796
|
} else {
|
|
1658
|
-
setChatMessages((prev) => [...prev, {role: "assistant", content:
|
|
1797
|
+
setChatMessages((prev) => [...prev, {role: "assistant", content: allAssistantText}]);
|
|
1659
1798
|
}
|
|
1660
1799
|
pushMessage("system", "");
|
|
1661
1800
|
} catch (error) {
|
|
1662
|
-
|
|
1801
|
+
// AbortError fires when user cancels — don't show as an error
|
|
1802
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
1803
|
+
pushMessage("error", `Error: ${error.message}`);
|
|
1804
|
+
}
|
|
1663
1805
|
} finally {
|
|
1806
|
+
abortRunRef.current = null;
|
|
1664
1807
|
setThinking(false);
|
|
1665
1808
|
}
|
|
1666
1809
|
},
|
|
@@ -1689,6 +1832,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1689
1832
|
]
|
|
1690
1833
|
);
|
|
1691
1834
|
|
|
1835
|
+
const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
|
|
1836
|
+
useEffect(() => {
|
|
1837
|
+
if (!thinking) {
|
|
1838
|
+
setThinkingColorIdx(0);
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
const interval = setInterval(() => {
|
|
1842
|
+
setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
|
|
1843
|
+
}, 120);
|
|
1844
|
+
return () => clearInterval(interval);
|
|
1845
|
+
}, [thinking]);
|
|
1846
|
+
|
|
1692
1847
|
useEffect(() => {
|
|
1693
1848
|
if (!voiceQueuedPrompt || !user || thinking) {
|
|
1694
1849
|
return;
|
|
@@ -1920,7 +2075,11 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1920
2075
|
<Text color="#7a9bba">
|
|
1921
2076
|
{`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
|
|
1922
2077
|
creditsRemaining ?? "--"
|
|
1923
|
-
}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}
|
|
2078
|
+
}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}${
|
|
2079
|
+
loopCtx
|
|
2080
|
+
? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
|
|
2081
|
+
: ""
|
|
2082
|
+
}`}
|
|
1924
2083
|
</Text>
|
|
1925
2084
|
<Text color="#6c88a8">
|
|
1926
2085
|
{`scope ${
|
|
@@ -1939,7 +2098,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1939
2098
|
</Text>
|
|
1940
2099
|
<Text color="#2a3a50">{divider}</Text>
|
|
1941
2100
|
<Text color="#3a5068">/help /new /history /voice /dictate on|off|status</Text>
|
|
1942
|
-
<Text color="#3a5068"
|
|
2101
|
+
<Text color="#3a5068">{isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit"}</Text>
|
|
1943
2102
|
<Text color="#2a3a50">{divider}</Text>
|
|
1944
2103
|
<Box flexDirection="column" marginTop={1}>
|
|
1945
2104
|
{messages.map((message, index) => {
|
|
@@ -1953,12 +2112,23 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1953
2112
|
message.kind !== "system" &&
|
|
1954
2113
|
prev?.kind !== message.kind &&
|
|
1955
2114
|
(message.kind === "assistant" || message.kind === "tool");
|
|
2115
|
+
// Show a subtle turn separator before each assistant response that
|
|
2116
|
+
// follows a tool block — makes it easy to see where one turn ends.
|
|
2117
|
+
const needsTurnSeparator =
|
|
2118
|
+
message.kind === "assistant" &&
|
|
2119
|
+
Boolean(prev) &&
|
|
2120
|
+
prev?.kind === "tool";
|
|
1956
2121
|
if (isSpacing) {
|
|
1957
2122
|
return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
|
|
1958
2123
|
}
|
|
1959
2124
|
|
|
1960
2125
|
if (message.kind === "tool" && message.card && !isSuperAdmin) {
|
|
1961
2126
|
const card = message.card;
|
|
2127
|
+
// In compact mode, hide "start" spinner cards — they create noise
|
|
2128
|
+
// (one per tool call) without adding signal after the run completes.
|
|
2129
|
+
if (toolFeedMode === "compact" && card.kind === "start") {
|
|
2130
|
+
return null;
|
|
2131
|
+
}
|
|
1962
2132
|
const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
|
|
1963
2133
|
const icon =
|
|
1964
2134
|
card.kind === "error"
|
|
@@ -1988,49 +2158,64 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1988
2158
|
: card.kind === "preview"
|
|
1989
2159
|
? "#9ad5ff"
|
|
1990
2160
|
: "#9bc5ff";
|
|
2161
|
+
// Timestamps fade with age: bright for recent, dim for older
|
|
2162
|
+
const tsAge = message.ts ? Date.now() - message.ts : Infinity;
|
|
2163
|
+
const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
|
|
1991
2164
|
return (
|
|
1992
2165
|
<React.Fragment key={`${index}-${message.kind}`}>
|
|
1993
|
-
{needsGroupGap ? <Text> </Text> : null}
|
|
2166
|
+
{needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
|
|
1994
2167
|
<Box flexDirection="row">
|
|
1995
2168
|
<Text color={style.labelColor}>{paddedLabel}</Text>
|
|
1996
|
-
<Box flexDirection="
|
|
1997
|
-
<
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
<Text
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2169
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
2170
|
+
<Box flexGrow={1} flexDirection="column">
|
|
2171
|
+
<Text color={accent}>
|
|
2172
|
+
{icon} {card.summary}
|
|
2173
|
+
{card.path ? <Text color="#ffffff"> {card.path}</Text> : null}
|
|
2174
|
+
{card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
|
|
2175
|
+
<Text color="#5a7a9a"> · {localityLabel}</Text>
|
|
2176
|
+
</Text>
|
|
2177
|
+
{toolFeedMode === "expanded" ? (
|
|
2178
|
+
<>
|
|
2179
|
+
{card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
|
|
2180
|
+
{(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
|
|
2181
|
+
<Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
|
|
2182
|
+
{line}
|
|
2183
|
+
</Text>
|
|
2184
|
+
))}
|
|
2185
|
+
</>
|
|
2186
|
+
) : null}
|
|
2187
|
+
</Box>
|
|
2188
|
+
{message.ts ? <Text color={tsColor}> {formatTime(message.ts)}</Text> : null}
|
|
2012
2189
|
</Box>
|
|
2013
2190
|
</Box>
|
|
2014
2191
|
</React.Fragment>
|
|
2015
2192
|
);
|
|
2016
2193
|
}
|
|
2017
2194
|
|
|
2195
|
+
const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
|
|
2196
|
+
const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
|
|
2197
|
+
const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
|
|
2018
2198
|
return (
|
|
2019
2199
|
<React.Fragment key={`${index}-${message.kind}`}>
|
|
2020
|
-
{needsGroupGap ? <Text> </Text> : null}
|
|
2200
|
+
{needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
|
|
2021
2201
|
<Box flexDirection="row">
|
|
2022
2202
|
<Text color={style.labelColor} bold={style.bold}>
|
|
2023
2203
|
{paddedLabel}
|
|
2024
2204
|
</Text>
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
{
|
|
2032
|
-
|
|
2033
|
-
|
|
2205
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
2206
|
+
{message.kind === "assistant" ? (
|
|
2207
|
+
<Box flexGrow={1} flexDirection="column">
|
|
2208
|
+
{renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
|
|
2209
|
+
</Box>
|
|
2210
|
+
) : (
|
|
2211
|
+
<Box flexGrow={1}>
|
|
2212
|
+
<Text color={style.textColor} bold={style.bold && message.kind === "error"}>
|
|
2213
|
+
{message.text}
|
|
2214
|
+
</Text>
|
|
2215
|
+
</Box>
|
|
2216
|
+
)}
|
|
2217
|
+
{showTs ? <Text color={tsColor2}> {formatTime(message.ts as number)}</Text> : null}
|
|
2218
|
+
</Box>
|
|
2034
2219
|
</Box>
|
|
2035
2220
|
</React.Fragment>
|
|
2036
2221
|
);
|
|
@@ -2043,12 +2228,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
2043
2228
|
) : null}
|
|
2044
2229
|
</Box>
|
|
2045
2230
|
<Text color="#2a3a50">{divider}</Text>
|
|
2046
|
-
{thinking ? (
|
|
2231
|
+
{thinking && !streamingText ? (
|
|
2047
2232
|
<Box flexDirection="row" marginTop={1}>
|
|
2048
2233
|
<Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
|
|
2049
|
-
<Text color="#
|
|
2234
|
+
<Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}>
|
|
2050
2235
|
<Spinner type="dots2" />
|
|
2051
|
-
<Text color="#
|
|
2236
|
+
<Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}> Working...</Text>
|
|
2237
|
+
<Text color="#2a4060"> esc to cancel</Text>
|
|
2052
2238
|
</Text>
|
|
2053
2239
|
</Box>
|
|
2054
2240
|
) : null}
|
|
@@ -2071,7 +2257,6 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
2071
2257
|
<TextInput
|
|
2072
2258
|
value={prompt}
|
|
2073
2259
|
onSubmit={(value) => {
|
|
2074
|
-
setPrompt("");
|
|
2075
2260
|
void sendPrompt(value);
|
|
2076
2261
|
}}
|
|
2077
2262
|
onChange={(value) => {
|