@astra-code/astra-ai 0.1.6 → 0.1.7
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 +181 -47
- package/dist/app/App.js.map +1 -1
- package/dist/lib/backendClient.js +87 -17
- package/dist/lib/backendClient.js.map +1 -1
- package/package.json +1 -1
- package/src/app/App.tsx +229 -77
- package/src/lib/backendClient.ts +88 -17
- 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";
|
|
@@ -198,6 +199,15 @@ const extractAssistantText = (event: AgentEvent): string | null => {
|
|
|
198
199
|
|
|
199
200
|
const LABEL_WIDTH = 10;
|
|
200
201
|
const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
|
|
202
|
+
|
|
203
|
+
const formatTime = (ts: number): string => {
|
|
204
|
+
const d = new Date(ts);
|
|
205
|
+
const raw = d.getHours();
|
|
206
|
+
const ampm = raw >= 12 ? "pm" : "am";
|
|
207
|
+
const h = raw % 12 === 0 ? 12 : raw % 12;
|
|
208
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
209
|
+
return `${h}:${m} ${ampm}`;
|
|
210
|
+
};
|
|
201
211
|
const MIN_DIVIDER = 64;
|
|
202
212
|
const MAX_DIVIDER = 120;
|
|
203
213
|
|
|
@@ -248,7 +258,13 @@ const normalizeAssistantText = (input: string): string => {
|
|
|
248
258
|
// Remove control chars but preserve newlines/tabs.
|
|
249
259
|
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
|
|
250
260
|
.replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
|
|
251
|
-
|
|
261
|
+
// Add space after sentence-ending punctuation only when followed by an
|
|
262
|
+
// uppercase letter (sentence start). Using [A-Za-z] here would break
|
|
263
|
+
// file extensions like .css, .json, .jsx, .tsx — those always start with
|
|
264
|
+
// a lowercase letter.
|
|
265
|
+
.replace(/([.!?])([A-Z])/g, "$1 $2")
|
|
266
|
+
// For ! and ? followed by lowercase, also add a space (natural English).
|
|
267
|
+
.replace(/([!?])([a-z])/g, "$1 $2")
|
|
252
268
|
.replace(/([a-z])(\u2022)/g, "$1\n$2")
|
|
253
269
|
.replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
|
|
254
270
|
.replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
|
|
@@ -277,34 +293,54 @@ const normalizeAssistantText = (input: string): string => {
|
|
|
277
293
|
return deduped.join("\n\n").trim();
|
|
278
294
|
};
|
|
279
295
|
|
|
280
|
-
const
|
|
296
|
+
const guessDevUrl = (command: string): string | null => {
|
|
297
|
+
// Extract an explicit --port or -p value from the command.
|
|
298
|
+
const portMatch = command.match(/(?:--port|-p)\s+(\d+)/) ?? command.match(/(?:--port=|-p=)(\d+)/);
|
|
299
|
+
if (portMatch) {
|
|
300
|
+
return `http://localhost:${portMatch[1]}`;
|
|
301
|
+
}
|
|
302
|
+
// Default ports by framework.
|
|
303
|
+
if (/next/.test(command)) return "http://localhost:3000";
|
|
304
|
+
if (/vite|vue/.test(command)) return "http://localhost:5173";
|
|
305
|
+
if (/remix/.test(command)) return "http://localhost:3000";
|
|
306
|
+
if (/astro/.test(command)) return "http://localhost:4321";
|
|
307
|
+
if (/angular|ng\s+serve/.test(command)) return "http://localhost:4200";
|
|
308
|
+
if (/npm\s+run\s+dev|npm\s+start|npx\s+react-scripts/.test(command)) return "http://localhost:3000";
|
|
309
|
+
return null;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const summarizeToolResult = (
|
|
313
|
+
toolName: string,
|
|
314
|
+
data: Record<string, unknown>,
|
|
315
|
+
success: boolean
|
|
316
|
+
): {summary: string; path?: string} => {
|
|
281
317
|
if (!success) {
|
|
282
|
-
return `${toolName} failed
|
|
318
|
+
return {summary: `${toolName} failed`};
|
|
283
319
|
}
|
|
284
320
|
const path = typeof data.path === "string" ? data.path : "";
|
|
285
321
|
const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
|
|
286
322
|
if (toolName === "view_file") {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
if (path)
|
|
291
|
-
|
|
292
|
-
}
|
|
323
|
+
const result: {summary: string; path?: string} = {
|
|
324
|
+
summary: totalLines !== null ? `Read ${totalLines} lines` : "Read file",
|
|
325
|
+
};
|
|
326
|
+
if (path) result.path = path;
|
|
327
|
+
return result;
|
|
293
328
|
}
|
|
294
329
|
if (toolName === "list_directory") {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
return `Listed <${dir}>`;
|
|
330
|
+
return {
|
|
331
|
+
summary: totalLines !== null ? `Listed ${totalLines} entries` : "Listed directory",
|
|
332
|
+
path: path || ".",
|
|
333
|
+
};
|
|
300
334
|
}
|
|
301
335
|
if (toolName === "semantic_search") {
|
|
302
|
-
return "Searched codebase context";
|
|
336
|
+
return {summary: "Searched codebase context"};
|
|
303
337
|
}
|
|
304
338
|
if (toolName === "search_files") {
|
|
305
|
-
return
|
|
339
|
+
return {
|
|
340
|
+
summary: totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths",
|
|
341
|
+
};
|
|
306
342
|
}
|
|
307
|
-
return `${toolName} completed
|
|
343
|
+
return {summary: `${toolName} completed`};
|
|
308
344
|
};
|
|
309
345
|
|
|
310
346
|
const isLikelyVoiceNoise = (text: string): boolean => {
|
|
@@ -602,9 +638,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
602
638
|
const backend = useMemo(() => new BackendClient(), []);
|
|
603
639
|
const {exit} = useApp();
|
|
604
640
|
|
|
641
|
+
// Keep backend token fresh: when the client auto-refreshes on 401, persist and update state.
|
|
642
|
+
useEffect(() => {
|
|
643
|
+
backend.setOnTokenRefreshed((refreshed) => {
|
|
644
|
+
saveSession(refreshed);
|
|
645
|
+
setUser(refreshed);
|
|
646
|
+
});
|
|
647
|
+
}, [backend]);
|
|
648
|
+
|
|
605
649
|
// In-session file cache: tracks files created/edited so subsequent requests
|
|
606
650
|
// include their latest content in workspaceFiles (VirtualFS stays up to date).
|
|
607
651
|
const localFileCache = useRef<Map<string, WorkspaceFile>>(new Map());
|
|
652
|
+
const abortRunRef = useRef<AbortController | null>(null);
|
|
608
653
|
|
|
609
654
|
const writeLocalFile = useCallback(
|
|
610
655
|
(relPath: string, content: string, language: string) => {
|
|
@@ -651,6 +696,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
651
696
|
const [activeModel, setActiveModel] = useState(getDefaultModel());
|
|
652
697
|
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
|
653
698
|
const [lastCreditCost, setLastCreditCost] = useState<number | null>(null);
|
|
699
|
+
const [loopCtx, setLoopCtx] = useState<{in: number; out: number} | null>(null);
|
|
654
700
|
const runtimeMode = getRuntimeMode();
|
|
655
701
|
const [prompt, setPrompt] = useState("");
|
|
656
702
|
const [thinking, setThinking] = useState(false);
|
|
@@ -662,6 +708,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
662
708
|
const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
|
|
663
709
|
const [micSetupDevices, setMicSetupDevices] = useState<Array<{index: number; name: string}> | null>(null);
|
|
664
710
|
const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
|
|
711
|
+
const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
|
|
665
712
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
666
713
|
const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
|
|
667
714
|
const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
|
|
@@ -677,12 +724,12 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
677
724
|
const isSuperAdmin = user?.role === "super_admin";
|
|
678
725
|
|
|
679
726
|
const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
|
|
680
|
-
setMessages((prev) => [...prev, {kind, text}].slice(-300));
|
|
727
|
+
setMessages((prev) => [...prev, {kind, text, ts: Date.now()}].slice(-300));
|
|
681
728
|
}, []);
|
|
682
729
|
|
|
683
730
|
const pushToolCard = useCallback((card: ToolCard) => {
|
|
684
731
|
setMessages((prev) => {
|
|
685
|
-
const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
|
|
732
|
+
const nextEntry = {kind: "tool", text: card.summary, card, ts: Date.now()} satisfies UiMessage;
|
|
686
733
|
const last = prev[prev.length - 1];
|
|
687
734
|
if (
|
|
688
735
|
last &&
|
|
@@ -991,6 +1038,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
991
1038
|
return;
|
|
992
1039
|
}
|
|
993
1040
|
|
|
1041
|
+
if (key.escape && thinking) {
|
|
1042
|
+
abortRunRef.current?.abort();
|
|
1043
|
+
pushMessage("system", "Cancelled.");
|
|
1044
|
+
pushMessage("system", "");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
994
1048
|
if (historyOpen) {
|
|
995
1049
|
if (key.escape) {
|
|
996
1050
|
if (historyMode === "sessions") {
|
|
@@ -1323,17 +1377,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1323
1377
|
}
|
|
1324
1378
|
|
|
1325
1379
|
if (!isSuperAdmin && event.tool_name === "start_preview") {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1380
|
+
// Server mode returns preview_url (tunnel). Desktop mode returns a
|
|
1381
|
+
// plain message — try to guess the local URL from the command.
|
|
1382
|
+
const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
|
|
1383
|
+
const command = typeof d.command === "string" ? d.command : "";
|
|
1384
|
+
const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
|
|
1385
|
+
const displayUrl = tunnelUrl ?? localUrl;
|
|
1332
1386
|
pushToolCard({
|
|
1333
1387
|
kind: "preview",
|
|
1334
1388
|
toolName: "start_preview",
|
|
1335
1389
|
locality,
|
|
1336
|
-
summary:
|
|
1390
|
+
summary: "Dev server running",
|
|
1391
|
+
...(displayUrl ? {path: displayUrl} : {}),
|
|
1337
1392
|
});
|
|
1338
1393
|
return null;
|
|
1339
1394
|
}
|
|
@@ -1383,6 +1438,16 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1383
1438
|
}
|
|
1384
1439
|
}
|
|
1385
1440
|
|
|
1441
|
+
if (event.type === "timing") {
|
|
1442
|
+
const ev = event as Record<string, unknown>;
|
|
1443
|
+
if (ev.phase === "llm_done") {
|
|
1444
|
+
const inTok = Number(ev.input_tokens ?? 0);
|
|
1445
|
+
const outTok = Number(ev.output_tokens ?? 0);
|
|
1446
|
+
if (inTok > 0 || outTok > 0) setLoopCtx({in: inTok, out: outTok});
|
|
1447
|
+
}
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1386
1451
|
if (event.type === "continuation_check") {
|
|
1387
1452
|
const recommendation =
|
|
1388
1453
|
typeof event.recommendation === "string" && event.recommendation
|
|
@@ -1426,12 +1491,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1426
1491
|
if (alreadyRepresented) {
|
|
1427
1492
|
return null;
|
|
1428
1493
|
}
|
|
1429
|
-
const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1494
|
+
const {summary, path: summaryPath} = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1430
1495
|
pushToolCard({
|
|
1431
1496
|
kind: event.success ? "success" : "error",
|
|
1432
1497
|
toolName,
|
|
1433
1498
|
locality: payload.local === true ? "LOCAL" : "REMOTE",
|
|
1434
|
-
summary: event.success ? summary : `${toolName} ${mark}
|
|
1499
|
+
summary: event.success ? summary : `${toolName} ${mark}`,
|
|
1500
|
+
...(summaryPath ? {path: summaryPath} : {}),
|
|
1435
1501
|
});
|
|
1436
1502
|
}
|
|
1437
1503
|
}
|
|
@@ -1443,7 +1509,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1443
1509
|
}
|
|
1444
1510
|
return null;
|
|
1445
1511
|
},
|
|
1446
|
-
[backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
|
|
1512
|
+
[backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]
|
|
1447
1513
|
);
|
|
1448
1514
|
|
|
1449
1515
|
const sendPrompt = useCallback(
|
|
@@ -1452,6 +1518,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1452
1518
|
if (!text || !user || thinking) {
|
|
1453
1519
|
return;
|
|
1454
1520
|
}
|
|
1521
|
+
setPrompt("");
|
|
1455
1522
|
|
|
1456
1523
|
// Mic onboarding: intercept when waiting for device selection.
|
|
1457
1524
|
if (micSetupDevices !== null) {
|
|
@@ -1478,18 +1545,30 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1478
1545
|
if (text === "/help") {
|
|
1479
1546
|
pushMessage(
|
|
1480
1547
|
"system",
|
|
1481
|
-
|
|
1548
|
+
isSuperAdmin
|
|
1549
|
+
? "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
|
|
1550
|
+
: "/new /history /voice /dictate on|off|status /settings /settings model <id> /logout /exit"
|
|
1482
1551
|
);
|
|
1483
1552
|
pushMessage("system", "");
|
|
1484
1553
|
return;
|
|
1485
1554
|
}
|
|
1486
1555
|
if (text === "/tools compact") {
|
|
1556
|
+
if (!isSuperAdmin) {
|
|
1557
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1558
|
+
pushMessage("system", "");
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1487
1561
|
setToolFeedMode("compact");
|
|
1488
1562
|
pushMessage("system", "Tool feed set to compact.");
|
|
1489
1563
|
pushMessage("system", "");
|
|
1490
1564
|
return;
|
|
1491
1565
|
}
|
|
1492
1566
|
if (text === "/tools expanded") {
|
|
1567
|
+
if (!isSuperAdmin) {
|
|
1568
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1569
|
+
pushMessage("system", "");
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1493
1572
|
setToolFeedMode("expanded");
|
|
1494
1573
|
pushMessage("system", "Tool feed set to expanded.");
|
|
1495
1574
|
pushMessage("system", "");
|
|
@@ -1605,6 +1684,10 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1605
1684
|
pushMessage("user", text);
|
|
1606
1685
|
setThinking(true);
|
|
1607
1686
|
setStreamingText("");
|
|
1687
|
+
setLoopCtx(null);
|
|
1688
|
+
|
|
1689
|
+
const abortController = new AbortController();
|
|
1690
|
+
abortRunRef.current = abortController;
|
|
1608
1691
|
|
|
1609
1692
|
try {
|
|
1610
1693
|
// Scan the local workspace so the backend VirtualFS is populated.
|
|
@@ -1615,8 +1698,28 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1615
1698
|
const seenPaths = new Set(sessionFiles.map((f) => f.path));
|
|
1616
1699
|
const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
|
|
1617
1700
|
|
|
1618
|
-
|
|
1701
|
+
// `pendingText` is text received since the last committed block.
|
|
1702
|
+
// It gets flushed to the messages list whenever tool activity starts,
|
|
1703
|
+
// keeping text and tool cards in the exact order they were emitted.
|
|
1704
|
+
let pendingText = "";
|
|
1705
|
+
let allAssistantText = "";
|
|
1619
1706
|
let localActionConfirmed = false;
|
|
1707
|
+
|
|
1708
|
+
const commitPending = (applyGuard = false) => {
|
|
1709
|
+
if (!pendingText.trim()) {
|
|
1710
|
+
pendingText = "";
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const clean = normalizeAssistantText(pendingText);
|
|
1714
|
+
const msg =
|
|
1715
|
+
applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
|
|
1716
|
+
? `Remote result (not yet confirmed as local filesystem change): ${clean}`
|
|
1717
|
+
: clean;
|
|
1718
|
+
pushMessage("assistant", msg);
|
|
1719
|
+
pendingText = "";
|
|
1720
|
+
setStreamingText("");
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1620
1723
|
for await (const event of backend.streamChat({
|
|
1621
1724
|
user,
|
|
1622
1725
|
sessionId: activeSessionId,
|
|
@@ -1624,7 +1727,8 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1624
1727
|
workspaceRoot,
|
|
1625
1728
|
workspaceTree,
|
|
1626
1729
|
workspaceFiles: mergedFiles,
|
|
1627
|
-
model: activeModel
|
|
1730
|
+
model: activeModel,
|
|
1731
|
+
signal: abortController.signal
|
|
1628
1732
|
})) {
|
|
1629
1733
|
if (event.type === "run_in_terminal") {
|
|
1630
1734
|
localActionConfirmed = true;
|
|
@@ -1638,29 +1742,35 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1638
1742
|
if (event.type === "done") {
|
|
1639
1743
|
break;
|
|
1640
1744
|
}
|
|
1745
|
+
// Flush any accumulated text before the first tool event so that text
|
|
1746
|
+
// appears above the tool cards that follow it — preserving order.
|
|
1747
|
+
if (event.type === "tool_start" || event.type === "run_in_terminal") {
|
|
1748
|
+
commitPending();
|
|
1749
|
+
}
|
|
1641
1750
|
const piece = await handleEvent(event, activeSessionId);
|
|
1642
1751
|
if (piece) {
|
|
1643
|
-
|
|
1644
|
-
|
|
1752
|
+
pendingText += piece;
|
|
1753
|
+
allAssistantText += piece;
|
|
1754
|
+
setStreamingText(normalizeAssistantText(pendingText));
|
|
1645
1755
|
}
|
|
1646
1756
|
}
|
|
1647
1757
|
|
|
1648
1758
|
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}]);
|
|
1759
|
+
commitPending(true);
|
|
1760
|
+
// Update conversation history for the backend with the full combined text.
|
|
1761
|
+
if (allAssistantText.trim()) {
|
|
1762
|
+
setChatMessages((prev) => [...prev, {role: "assistant", content: normalizeAssistantText(allAssistantText)}]);
|
|
1657
1763
|
} else {
|
|
1658
|
-
setChatMessages((prev) => [...prev, {role: "assistant", content:
|
|
1764
|
+
setChatMessages((prev) => [...prev, {role: "assistant", content: allAssistantText}]);
|
|
1659
1765
|
}
|
|
1660
1766
|
pushMessage("system", "");
|
|
1661
1767
|
} catch (error) {
|
|
1662
|
-
|
|
1768
|
+
// AbortError fires when user cancels — don't show as an error
|
|
1769
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
1770
|
+
pushMessage("error", `Error: ${error.message}`);
|
|
1771
|
+
}
|
|
1663
1772
|
} finally {
|
|
1773
|
+
abortRunRef.current = null;
|
|
1664
1774
|
setThinking(false);
|
|
1665
1775
|
}
|
|
1666
1776
|
},
|
|
@@ -1689,6 +1799,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1689
1799
|
]
|
|
1690
1800
|
);
|
|
1691
1801
|
|
|
1802
|
+
const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
|
|
1803
|
+
useEffect(() => {
|
|
1804
|
+
if (!thinking) {
|
|
1805
|
+
setThinkingColorIdx(0);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
const interval = setInterval(() => {
|
|
1809
|
+
setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
|
|
1810
|
+
}, 120);
|
|
1811
|
+
return () => clearInterval(interval);
|
|
1812
|
+
}, [thinking]);
|
|
1813
|
+
|
|
1692
1814
|
useEffect(() => {
|
|
1693
1815
|
if (!voiceQueuedPrompt || !user || thinking) {
|
|
1694
1816
|
return;
|
|
@@ -1920,7 +2042,11 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1920
2042
|
<Text color="#7a9bba">
|
|
1921
2043
|
{`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
|
|
1922
2044
|
creditsRemaining ?? "--"
|
|
1923
|
-
}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}
|
|
2045
|
+
}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}${
|
|
2046
|
+
loopCtx
|
|
2047
|
+
? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
|
|
2048
|
+
: ""
|
|
2049
|
+
}`}
|
|
1924
2050
|
</Text>
|
|
1925
2051
|
<Text color="#6c88a8">
|
|
1926
2052
|
{`scope ${
|
|
@@ -1939,7 +2065,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1939
2065
|
</Text>
|
|
1940
2066
|
<Text color="#2a3a50">{divider}</Text>
|
|
1941
2067
|
<Text color="#3a5068">/help /new /history /voice /dictate on|off|status</Text>
|
|
1942
|
-
<Text color="#3a5068"
|
|
2068
|
+
<Text color="#3a5068">{isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit"}</Text>
|
|
1943
2069
|
<Text color="#2a3a50">{divider}</Text>
|
|
1944
2070
|
<Box flexDirection="column" marginTop={1}>
|
|
1945
2071
|
{messages.map((message, index) => {
|
|
@@ -1953,12 +2079,23 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1953
2079
|
message.kind !== "system" &&
|
|
1954
2080
|
prev?.kind !== message.kind &&
|
|
1955
2081
|
(message.kind === "assistant" || message.kind === "tool");
|
|
2082
|
+
// Show a subtle turn separator before each assistant response that
|
|
2083
|
+
// follows a tool block — makes it easy to see where one turn ends.
|
|
2084
|
+
const needsTurnSeparator =
|
|
2085
|
+
message.kind === "assistant" &&
|
|
2086
|
+
Boolean(prev) &&
|
|
2087
|
+
prev?.kind === "tool";
|
|
1956
2088
|
if (isSpacing) {
|
|
1957
2089
|
return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
|
|
1958
2090
|
}
|
|
1959
2091
|
|
|
1960
2092
|
if (message.kind === "tool" && message.card && !isSuperAdmin) {
|
|
1961
2093
|
const card = message.card;
|
|
2094
|
+
// In compact mode, hide "start" spinner cards — they create noise
|
|
2095
|
+
// (one per tool call) without adding signal after the run completes.
|
|
2096
|
+
if (toolFeedMode === "compact" && card.kind === "start") {
|
|
2097
|
+
return null;
|
|
2098
|
+
}
|
|
1962
2099
|
const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
|
|
1963
2100
|
const icon =
|
|
1964
2101
|
card.kind === "error"
|
|
@@ -1988,49 +2125,64 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1988
2125
|
: card.kind === "preview"
|
|
1989
2126
|
? "#9ad5ff"
|
|
1990
2127
|
: "#9bc5ff";
|
|
2128
|
+
// Timestamps fade with age: bright for recent, dim for older
|
|
2129
|
+
const tsAge = message.ts ? Date.now() - message.ts : Infinity;
|
|
2130
|
+
const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
|
|
1991
2131
|
return (
|
|
1992
2132
|
<React.Fragment key={`${index}-${message.kind}`}>
|
|
1993
|
-
{needsGroupGap ? <Text> </Text> : null}
|
|
2133
|
+
{needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
|
|
1994
2134
|
<Box flexDirection="row">
|
|
1995
2135
|
<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
|
-
|
|
2136
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
2137
|
+
<Box flexGrow={1} flexDirection="column">
|
|
2138
|
+
<Text color={accent}>
|
|
2139
|
+
{icon} {card.summary}
|
|
2140
|
+
{card.path ? <Text color="#ffffff"> {card.path}</Text> : null}
|
|
2141
|
+
{card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
|
|
2142
|
+
<Text color="#5a7a9a"> · {localityLabel}</Text>
|
|
2143
|
+
</Text>
|
|
2144
|
+
{toolFeedMode === "expanded" ? (
|
|
2145
|
+
<>
|
|
2146
|
+
{card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
|
|
2147
|
+
{(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
|
|
2148
|
+
<Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
|
|
2149
|
+
{line}
|
|
2150
|
+
</Text>
|
|
2151
|
+
))}
|
|
2152
|
+
</>
|
|
2153
|
+
) : null}
|
|
2154
|
+
</Box>
|
|
2155
|
+
{message.ts ? <Text color={tsColor}> {formatTime(message.ts)}</Text> : null}
|
|
2012
2156
|
</Box>
|
|
2013
2157
|
</Box>
|
|
2014
2158
|
</React.Fragment>
|
|
2015
2159
|
);
|
|
2016
2160
|
}
|
|
2017
2161
|
|
|
2162
|
+
const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
|
|
2163
|
+
const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
|
|
2164
|
+
const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
|
|
2018
2165
|
return (
|
|
2019
2166
|
<React.Fragment key={`${index}-${message.kind}`}>
|
|
2020
|
-
{needsGroupGap ? <Text> </Text> : null}
|
|
2167
|
+
{needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
|
|
2021
2168
|
<Box flexDirection="row">
|
|
2022
2169
|
<Text color={style.labelColor} bold={style.bold}>
|
|
2023
2170
|
{paddedLabel}
|
|
2024
2171
|
</Text>
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
{
|
|
2032
|
-
|
|
2033
|
-
|
|
2172
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
2173
|
+
{message.kind === "assistant" ? (
|
|
2174
|
+
<Box flexGrow={1} flexDirection="column">
|
|
2175
|
+
{renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
|
|
2176
|
+
</Box>
|
|
2177
|
+
) : (
|
|
2178
|
+
<Box flexGrow={1}>
|
|
2179
|
+
<Text color={style.textColor} bold={style.bold && message.kind === "error"}>
|
|
2180
|
+
{message.text}
|
|
2181
|
+
</Text>
|
|
2182
|
+
</Box>
|
|
2183
|
+
)}
|
|
2184
|
+
{showTs ? <Text color={tsColor2}> {formatTime(message.ts as number)}</Text> : null}
|
|
2185
|
+
</Box>
|
|
2034
2186
|
</Box>
|
|
2035
2187
|
</React.Fragment>
|
|
2036
2188
|
);
|
|
@@ -2043,12 +2195,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
2043
2195
|
) : null}
|
|
2044
2196
|
</Box>
|
|
2045
2197
|
<Text color="#2a3a50">{divider}</Text>
|
|
2046
|
-
{thinking ? (
|
|
2198
|
+
{thinking && !streamingText ? (
|
|
2047
2199
|
<Box flexDirection="row" marginTop={1}>
|
|
2048
2200
|
<Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
|
|
2049
|
-
<Text color="#
|
|
2201
|
+
<Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}>
|
|
2050
2202
|
<Spinner type="dots2" />
|
|
2051
|
-
<Text color="#
|
|
2203
|
+
<Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}> Working...</Text>
|
|
2204
|
+
<Text color="#2a4060"> esc to cancel</Text>
|
|
2052
2205
|
</Text>
|
|
2053
2206
|
</Box>
|
|
2054
2207
|
) : null}
|
|
@@ -2071,7 +2224,6 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
2071
2224
|
<TextInput
|
|
2072
2225
|
value={prompt}
|
|
2073
2226
|
onSubmit={(value) => {
|
|
2074
|
-
setPrompt("");
|
|
2075
2227
|
void sendPrompt(value);
|
|
2076
2228
|
}}
|
|
2077
2229
|
onChange={(value) => {
|