@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/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 ?? "4");
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(["you", "yes", "yeah", "yep", "ok", "okay", "uh", "um", "hmm"]);
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
- .replace(/([.!?])([A-Za-z])/g, "$1 $2")
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 summarizeToolResult = (toolName: string, data: Record<string, unknown>, success: boolean): string => {
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
- if (totalLines !== null && path) {
288
- return `Read ${totalLines} lines of <${path}>`;
289
- }
290
- if (path) {
291
- return `Read <${path}>`;
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
- const dir = path || ".";
296
- if (totalLines !== null) {
297
- return `Listed ${totalLines} entries in <${dir}>`;
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 totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths";
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
- return true;
314
- }
315
- if (normalized.length < VOICE_MIN_CHARS) {
316
- return true;
317
- }
318
- const tokens = normalized.split(/\s+/).filter(Boolean);
319
- if (tokens.length === 0) {
320
- return true;
321
- }
322
- const nonNoise = tokens.filter((t) => !VOICE_NOISE_WORDS.has(t));
323
- if (nonNoise.length === 0) {
324
- return true;
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
- const message =
1327
- typeof d.message === "string"
1328
- ? d.message
1329
- : typeof d.preview_url === "string"
1330
- ? `Preview: ${d.preview_url}`
1331
- : "Local preview started.";
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: message
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
- "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
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
- let assistant = "";
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
- assistant += piece;
1644
- setStreamingText(normalizeAssistantText(assistant));
1785
+ pendingText += piece;
1786
+ allAssistantText += piece;
1787
+ setStreamingText(normalizeAssistantText(pendingText));
1645
1788
  }
1646
1789
  }
1647
1790
 
1648
1791
  setStreamingText("");
1649
- if (assistant.trim()) {
1650
- const cleanedAssistant = normalizeAssistantText(assistant);
1651
- const guardedAssistant =
1652
- !localActionConfirmed && looksLikeLocalFilesystemClaim(cleanedAssistant)
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: assistant}]);
1797
+ setChatMessages((prev) => [...prev, {role: "assistant", content: allAssistantText}]);
1659
1798
  }
1660
1799
  pushMessage("system", "");
1661
1800
  } catch (error) {
1662
- pushMessage("error", `Error: ${error instanceof Error ? error.message : String(error)}`);
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">/tools compact|expanded /settings /logout /exit</Text>
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="column">
1997
- <Text color={accent}>
1998
- {icon} {card.summary}
1999
- {card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
2000
- <Text color="#5a7a9a"> · {localityLabel}</Text>
2001
- </Text>
2002
- {toolFeedMode === "expanded" ? (
2003
- <>
2004
- {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
2005
- {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
2006
- <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
2007
- {line}
2008
- </Text>
2009
- ))}
2010
- </>
2011
- ) : null}
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
- {message.kind === "assistant" ? (
2026
- <Box flexDirection="column">
2027
- {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
2028
- </Box>
2029
- ) : (
2030
- <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
2031
- {message.text}
2032
- </Text>
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="#6080a0">
2234
+ <Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}>
2050
2235
  <Spinner type="dots2" />
2051
- <Text color="#8aa2c9"> thinking...</Text>
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) => {