@astra-code/astra-ai 0.1.5 → 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/src/app/App.tsx CHANGED
@@ -3,7 +3,7 @@ import {Box, Text, useApp, useInput} from "ink";
3
3
  import Spinner from "ink-spinner";
4
4
  import TextInput from "ink-text-input";
5
5
  import {spawn} from "child_process";
6
- import {mkdirSync, unlinkSync, writeFileSync} from "fs";
6
+ import {existsSync, mkdirSync, unlinkSync, writeFileSync} from "fs";
7
7
  import {dirname, join} from "path";
8
8
  import {BackendClient, type SessionSummary} from "../lib/backendClient.js";
9
9
  import {clearSession, loadSession, saveSession} from "../lib/sessionStore.js";
@@ -17,7 +17,7 @@ import {
17
17
  import {runTerminalCommand} from "../lib/terminalBridge.js";
18
18
  import {isWorkspaceTrusted, trustWorkspace} from "../lib/trustStore.js";
19
19
  import {scanWorkspace} from "../lib/workspaceScanner.js";
20
- import {startLiveTranscription, transcribeOnce, type LiveTranscriptionController} from "../lib/voice.js";
20
+ import {startLiveTranscription, transcribeOnce, resolveAudioDevice, setAudioDevice, listAvfAudioDevices, writeAstraKey, type LiveTranscriptionController} from "../lib/voice.js";
21
21
  import type {AgentEvent, AuthSession, ChatMessage} from "../types/events.js";
22
22
  import type {WorkspaceFile} from "../lib/workspaceScanner.js";
23
23
 
@@ -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
- .replace(/([.!?])([A-Za-z])/g, "$1 $2")
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 summarizeToolResult = (toolName: string, data: Record<string, unknown>, success: boolean): string => {
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
- if (totalLines !== null && path) {
288
- return `Read ${totalLines} lines of <${path}>`;
289
- }
290
- if (path) {
291
- return `Read <${path}>`;
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
- const dir = path || ".";
296
- if (totalLines !== null) {
297
- return `Listed ${totalLines} entries in <${dir}>`;
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 totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths";
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);
@@ -660,7 +706,9 @@ export const AstraApp = (): React.JSX.Element => {
660
706
  const [voicePreparing, setVoicePreparing] = useState(false);
661
707
  const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
662
708
  const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
709
+ const [micSetupDevices, setMicSetupDevices] = useState<Array<{index: number; name: string}> | null>(null);
663
710
  const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
711
+ const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
664
712
  const [historyOpen, setHistoryOpen] = useState(false);
665
713
  const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
666
714
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -676,12 +724,12 @@ export const AstraApp = (): React.JSX.Element => {
676
724
  const isSuperAdmin = user?.role === "super_admin";
677
725
 
678
726
  const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
679
- setMessages((prev) => [...prev, {kind, text}].slice(-300));
727
+ setMessages((prev) => [...prev, {kind, text, ts: Date.now()}].slice(-300));
680
728
  }, []);
681
729
 
682
730
  const pushToolCard = useCallback((card: ToolCard) => {
683
731
  setMessages((prev) => {
684
- const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
732
+ const nextEntry = {kind: "tool", text: card.summary, card, ts: Date.now()} satisfies UiMessage;
685
733
  const last = prev[prev.length - 1];
686
734
  if (
687
735
  last &&
@@ -801,7 +849,29 @@ export const AstraApp = (): React.JSX.Element => {
801
849
  if (announce) {
802
850
  pushMessage("system", "Voice input armed. Preparing microphone...");
803
851
  }
804
- liveVoiceRef.current = startLiveTranscription({
852
+
853
+ // Resolve mic device before starting — triggers onboarding if not configured.
854
+ void resolveAudioDevice(workspaceRoot).then((device) => {
855
+ if (device === null) {
856
+ // No device configured — run onboarding inline.
857
+ setVoicePreparing(false);
858
+ const devices = listAvfAudioDevices();
859
+ if (!devices.length) {
860
+ pushMessage("error", "No audio devices found. Install ffmpeg: brew install ffmpeg");
861
+ setVoiceEnabled(false);
862
+ return;
863
+ }
864
+ setMicSetupDevices(devices);
865
+ const lines = [
866
+ "Let's set up your microphone first.",
867
+ ...devices.map(d => ` [${d.index}] ${d.name}`),
868
+ "Type the number for your mic and press Enter:"
869
+ ];
870
+ pushMessage("system", lines.join("\n"));
871
+ return;
872
+ }
873
+ // Device resolved — start transcription.
874
+ liveVoiceRef.current = startLiveTranscription({
805
875
  onPartial: (text) => {
806
876
  setVoicePreparing(false);
807
877
  setVoiceListening(true);
@@ -857,8 +927,9 @@ export const AstraApp = (): React.JSX.Element => {
857
927
  fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H4',location:'App.tsx:startLiveVoice.onError',message:'voice transcription error',data:{error:error.message},timestamp:Date.now()})}).catch(()=>{});
858
928
  // #endregion
859
929
  }
860
- });
861
- }, [pushMessage, stopLiveVoice]);
930
+ });
931
+ }); // end resolveAudioDevice.then
932
+ }, [pushMessage, stopLiveVoice, workspaceRoot]);
862
933
 
863
934
  useEffect(() => {
864
935
  return () => {
@@ -947,6 +1018,11 @@ export const AstraApp = (): React.JSX.Element => {
947
1018
  if (key.return) {
948
1019
  if (trustSelection === 0) {
949
1020
  trustWorkspace(workspaceRoot);
1021
+ // Create .astra settings file at workspace root if it doesn't exist yet.
1022
+ try {
1023
+ const astraPath = join(workspaceRoot, ".astra");
1024
+ if (!existsSync(astraPath)) writeFileSync(astraPath, "");
1025
+ } catch { /* non-fatal */ }
950
1026
  setTrustedWorkspace(true);
951
1027
  setBooting(true);
952
1028
  return;
@@ -962,6 +1038,13 @@ export const AstraApp = (): React.JSX.Element => {
962
1038
  return;
963
1039
  }
964
1040
 
1041
+ if (key.escape && thinking) {
1042
+ abortRunRef.current?.abort();
1043
+ pushMessage("system", "Cancelled.");
1044
+ pushMessage("system", "");
1045
+ return;
1046
+ }
1047
+
965
1048
  if (historyOpen) {
966
1049
  if (key.escape) {
967
1050
  if (historyMode === "sessions") {
@@ -1294,17 +1377,18 @@ export const AstraApp = (): React.JSX.Element => {
1294
1377
  }
1295
1378
 
1296
1379
  if (!isSuperAdmin && event.tool_name === "start_preview") {
1297
- const message =
1298
- typeof d.message === "string"
1299
- ? d.message
1300
- : typeof d.preview_url === "string"
1301
- ? `Preview: ${d.preview_url}`
1302
- : "Local preview started.";
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;
1303
1386
  pushToolCard({
1304
1387
  kind: "preview",
1305
1388
  toolName: "start_preview",
1306
1389
  locality,
1307
- summary: message
1390
+ summary: "Dev server running",
1391
+ ...(displayUrl ? {path: displayUrl} : {}),
1308
1392
  });
1309
1393
  return null;
1310
1394
  }
@@ -1354,6 +1438,16 @@ export const AstraApp = (): React.JSX.Element => {
1354
1438
  }
1355
1439
  }
1356
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
+
1357
1451
  if (event.type === "continuation_check") {
1358
1452
  const recommendation =
1359
1453
  typeof event.recommendation === "string" && event.recommendation
@@ -1397,12 +1491,13 @@ export const AstraApp = (): React.JSX.Element => {
1397
1491
  if (alreadyRepresented) {
1398
1492
  return null;
1399
1493
  }
1400
- const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
1494
+ const {summary, path: summaryPath} = summarizeToolResult(toolName, payload, Boolean(event.success));
1401
1495
  pushToolCard({
1402
1496
  kind: event.success ? "success" : "error",
1403
1497
  toolName,
1404
1498
  locality: payload.local === true ? "LOCAL" : "REMOTE",
1405
- summary: event.success ? summary : `${toolName} ${mark}`
1499
+ summary: event.success ? summary : `${toolName} ${mark}`,
1500
+ ...(summaryPath ? {path: summaryPath} : {}),
1406
1501
  });
1407
1502
  }
1408
1503
  }
@@ -1414,7 +1509,7 @@ export const AstraApp = (): React.JSX.Element => {
1414
1509
  }
1415
1510
  return null;
1416
1511
  },
1417
- [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
1512
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]
1418
1513
  );
1419
1514
 
1420
1515
  const sendPrompt = useCallback(
@@ -1423,22 +1518,57 @@ export const AstraApp = (): React.JSX.Element => {
1423
1518
  if (!text || !user || thinking) {
1424
1519
  return;
1425
1520
  }
1521
+ setPrompt("");
1522
+
1523
+ // Mic onboarding: intercept when waiting for device selection.
1524
+ if (micSetupDevices !== null) {
1525
+ const idx = parseInt(text, 10);
1526
+ const valid = !isNaN(idx) && idx >= 0 && micSetupDevices.some(d => d.index === idx);
1527
+ if (!valid) {
1528
+ pushMessage("error", `Please type one of: ${micSetupDevices.map(d => d.index).join(", ")}`);
1529
+ return;
1530
+ }
1531
+ const device = `:${idx}`;
1532
+ // Write to .astra local cache
1533
+ writeAstraKey(workspaceRoot, "ASTRA_STT_DEVICE", device);
1534
+ // Persist to backend
1535
+ void backend.updateCliSettings({audio_device_index: idx});
1536
+ // Update in-process cache
1537
+ setAudioDevice(device);
1538
+ setMicSetupDevices(null);
1539
+ pushMessage("system", `Mic set to [${idx}] ${micSetupDevices.find(d => d.index === idx)?.name ?? ""}. Starting voice...`);
1540
+ pushMessage("system", "");
1541
+ startLiveVoice(false);
1542
+ return;
1543
+ }
1426
1544
 
1427
1545
  if (text === "/help") {
1428
1546
  pushMessage(
1429
1547
  "system",
1430
- "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
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"
1431
1551
  );
1432
1552
  pushMessage("system", "");
1433
1553
  return;
1434
1554
  }
1435
1555
  if (text === "/tools compact") {
1556
+ if (!isSuperAdmin) {
1557
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1558
+ pushMessage("system", "");
1559
+ return;
1560
+ }
1436
1561
  setToolFeedMode("compact");
1437
1562
  pushMessage("system", "Tool feed set to compact.");
1438
1563
  pushMessage("system", "");
1439
1564
  return;
1440
1565
  }
1441
1566
  if (text === "/tools expanded") {
1567
+ if (!isSuperAdmin) {
1568
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1569
+ pushMessage("system", "");
1570
+ return;
1571
+ }
1442
1572
  setToolFeedMode("expanded");
1443
1573
  pushMessage("system", "Tool feed set to expanded.");
1444
1574
  pushMessage("system", "");
@@ -1554,6 +1684,10 @@ export const AstraApp = (): React.JSX.Element => {
1554
1684
  pushMessage("user", text);
1555
1685
  setThinking(true);
1556
1686
  setStreamingText("");
1687
+ setLoopCtx(null);
1688
+
1689
+ const abortController = new AbortController();
1690
+ abortRunRef.current = abortController;
1557
1691
 
1558
1692
  try {
1559
1693
  // Scan the local workspace so the backend VirtualFS is populated.
@@ -1564,8 +1698,28 @@ export const AstraApp = (): React.JSX.Element => {
1564
1698
  const seenPaths = new Set(sessionFiles.map((f) => f.path));
1565
1699
  const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
1566
1700
 
1567
- let assistant = "";
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 = "";
1568
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
+
1569
1723
  for await (const event of backend.streamChat({
1570
1724
  user,
1571
1725
  sessionId: activeSessionId,
@@ -1573,7 +1727,8 @@ export const AstraApp = (): React.JSX.Element => {
1573
1727
  workspaceRoot,
1574
1728
  workspaceTree,
1575
1729
  workspaceFiles: mergedFiles,
1576
- model: activeModel
1730
+ model: activeModel,
1731
+ signal: abortController.signal
1577
1732
  })) {
1578
1733
  if (event.type === "run_in_terminal") {
1579
1734
  localActionConfirmed = true;
@@ -1587,29 +1742,35 @@ export const AstraApp = (): React.JSX.Element => {
1587
1742
  if (event.type === "done") {
1588
1743
  break;
1589
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
+ }
1590
1750
  const piece = await handleEvent(event, activeSessionId);
1591
1751
  if (piece) {
1592
- assistant += piece;
1593
- setStreamingText(normalizeAssistantText(assistant));
1752
+ pendingText += piece;
1753
+ allAssistantText += piece;
1754
+ setStreamingText(normalizeAssistantText(pendingText));
1594
1755
  }
1595
1756
  }
1596
1757
 
1597
1758
  setStreamingText("");
1598
- if (assistant.trim()) {
1599
- const cleanedAssistant = normalizeAssistantText(assistant);
1600
- const guardedAssistant =
1601
- !localActionConfirmed && looksLikeLocalFilesystemClaim(cleanedAssistant)
1602
- ? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
1603
- : cleanedAssistant;
1604
- pushMessage("assistant", guardedAssistant);
1605
- 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)}]);
1606
1763
  } else {
1607
- setChatMessages((prev) => [...prev, {role: "assistant", content: assistant}]);
1764
+ setChatMessages((prev) => [...prev, {role: "assistant", content: allAssistantText}]);
1608
1765
  }
1609
1766
  pushMessage("system", "");
1610
1767
  } catch (error) {
1611
- pushMessage("error", `Error: ${error instanceof Error ? error.message : String(error)}`);
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
+ }
1612
1772
  } finally {
1773
+ abortRunRef.current = null;
1613
1774
  setThinking(false);
1614
1775
  }
1615
1776
  },
@@ -1620,9 +1781,11 @@ export const AstraApp = (): React.JSX.Element => {
1620
1781
  exit,
1621
1782
  handleEvent,
1622
1783
  localFileCache,
1784
+ micSetupDevices,
1623
1785
  openHistory,
1624
1786
  pushMessage,
1625
1787
  sessionId,
1788
+ setMicSetupDevices,
1626
1789
  startLiveVoice,
1627
1790
  stopLiveVoice,
1628
1791
  thinking,
@@ -1636,6 +1799,18 @@ export const AstraApp = (): React.JSX.Element => {
1636
1799
  ]
1637
1800
  );
1638
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
+
1639
1814
  useEffect(() => {
1640
1815
  if (!voiceQueuedPrompt || !user || thinking) {
1641
1816
  return;
@@ -1867,7 +2042,11 @@ export const AstraApp = (): React.JSX.Element => {
1867
2042
  <Text color="#7a9bba">
1868
2043
  {`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
1869
2044
  creditsRemaining ?? "--"
1870
- }${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
+ }`}
1871
2050
  </Text>
1872
2051
  <Text color="#6c88a8">
1873
2052
  {`scope ${
@@ -1886,7 +2065,7 @@ export const AstraApp = (): React.JSX.Element => {
1886
2065
  </Text>
1887
2066
  <Text color="#2a3a50">{divider}</Text>
1888
2067
  <Text color="#3a5068">/help /new /history /voice /dictate on|off|status</Text>
1889
- <Text color="#3a5068">/tools compact|expanded /settings /logout /exit</Text>
2068
+ <Text color="#3a5068">{isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit"}</Text>
1890
2069
  <Text color="#2a3a50">{divider}</Text>
1891
2070
  <Box flexDirection="column" marginTop={1}>
1892
2071
  {messages.map((message, index) => {
@@ -1900,12 +2079,23 @@ export const AstraApp = (): React.JSX.Element => {
1900
2079
  message.kind !== "system" &&
1901
2080
  prev?.kind !== message.kind &&
1902
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";
1903
2088
  if (isSpacing) {
1904
2089
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1905
2090
  }
1906
2091
 
1907
2092
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1908
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
+ }
1909
2099
  const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1910
2100
  const icon =
1911
2101
  card.kind === "error"
@@ -1935,49 +2125,64 @@ export const AstraApp = (): React.JSX.Element => {
1935
2125
  : card.kind === "preview"
1936
2126
  ? "#9ad5ff"
1937
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";
1938
2131
  return (
1939
2132
  <React.Fragment key={`${index}-${message.kind}`}>
1940
- {needsGroupGap ? <Text> </Text> : null}
2133
+ {needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
1941
2134
  <Box flexDirection="row">
1942
2135
  <Text color={style.labelColor}>{paddedLabel}</Text>
1943
- <Box flexDirection="column">
1944
- <Text color={accent}>
1945
- {icon} {card.summary}
1946
- {card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
1947
- <Text color="#5a7a9a"> · {localityLabel}</Text>
1948
- </Text>
1949
- {toolFeedMode === "expanded" ? (
1950
- <>
1951
- {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
1952
- {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
1953
- <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
1954
- {line}
1955
- </Text>
1956
- ))}
1957
- </>
1958
- ) : null}
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}
1959
2156
  </Box>
1960
2157
  </Box>
1961
2158
  </React.Fragment>
1962
2159
  );
1963
2160
  }
1964
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";
1965
2165
  return (
1966
2166
  <React.Fragment key={`${index}-${message.kind}`}>
1967
- {needsGroupGap ? <Text> </Text> : null}
2167
+ {needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
1968
2168
  <Box flexDirection="row">
1969
2169
  <Text color={style.labelColor} bold={style.bold}>
1970
2170
  {paddedLabel}
1971
2171
  </Text>
1972
- {message.kind === "assistant" ? (
1973
- <Box flexDirection="column">
1974
- {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
1975
- </Box>
1976
- ) : (
1977
- <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
1978
- {message.text}
1979
- </Text>
1980
- )}
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>
1981
2186
  </Box>
1982
2187
  </React.Fragment>
1983
2188
  );
@@ -1990,12 +2195,13 @@ export const AstraApp = (): React.JSX.Element => {
1990
2195
  ) : null}
1991
2196
  </Box>
1992
2197
  <Text color="#2a3a50">{divider}</Text>
1993
- {thinking ? (
2198
+ {thinking && !streamingText ? (
1994
2199
  <Box flexDirection="row" marginTop={1}>
1995
2200
  <Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
1996
- <Text color="#6080a0">
2201
+ <Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}>
1997
2202
  <Spinner type="dots2" />
1998
- <Text color="#8aa2c9"> thinking...</Text>
2203
+ <Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}> Working...</Text>
2204
+ <Text color="#2a4060"> esc to cancel</Text>
1999
2205
  </Text>
2000
2206
  </Box>
2001
2207
  ) : null}
@@ -2018,7 +2224,6 @@ export const AstraApp = (): React.JSX.Element => {
2018
2224
  <TextInput
2019
2225
  value={prompt}
2020
2226
  onSubmit={(value) => {
2021
- setPrompt("");
2022
2227
  void sendPrompt(value);
2023
2228
  }}
2024
2229
  onChange={(value) => {