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