@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 CHANGED
@@ -151,6 +151,14 @@ const extractAssistantText = (event) => {
151
151
  };
152
152
  const LABEL_WIDTH = 10;
153
153
  const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
154
+ const formatTime = (ts) => {
155
+ const d = new Date(ts);
156
+ const raw = d.getHours();
157
+ const ampm = raw >= 12 ? "pm" : "am";
158
+ const h = raw % 12 === 0 ? 12 : raw % 12;
159
+ const m = d.getMinutes().toString().padStart(2, "0");
160
+ return `${h}:${m} ${ampm}`;
161
+ };
154
162
  const MIN_DIVIDER = 64;
155
163
  const MAX_DIVIDER = 120;
156
164
  const styleForKind = (kind) => {
@@ -193,7 +201,13 @@ const normalizeAssistantText = (input) => {
193
201
  // Remove control chars but preserve newlines/tabs.
194
202
  .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
195
203
  .replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
196
- .replace(/([.!?])([A-Za-z])/g, "$1 $2")
204
+ // Add space after sentence-ending punctuation only when followed by an
205
+ // uppercase letter (sentence start). Using [A-Za-z] here would break
206
+ // file extensions like .css, .json, .jsx, .tsx — those always start with
207
+ // a lowercase letter.
208
+ .replace(/([.!?])([A-Z])/g, "$1 $2")
209
+ // For ! and ? followed by lowercase, also add a space (natural English).
210
+ .replace(/([!?])([a-z])/g, "$1 $2")
197
211
  .replace(/([a-z])(\u2022)/g, "$1\n$2")
198
212
  .replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
199
213
  .replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
@@ -220,34 +234,56 @@ const normalizeAssistantText = (input) => {
220
234
  }
221
235
  return deduped.join("\n\n").trim();
222
236
  };
237
+ const guessDevUrl = (command) => {
238
+ // Extract an explicit --port or -p value from the command.
239
+ const portMatch = command.match(/(?:--port|-p)\s+(\d+)/) ?? command.match(/(?:--port=|-p=)(\d+)/);
240
+ if (portMatch) {
241
+ return `http://localhost:${portMatch[1]}`;
242
+ }
243
+ // Default ports by framework.
244
+ if (/next/.test(command))
245
+ return "http://localhost:3000";
246
+ if (/vite|vue/.test(command))
247
+ return "http://localhost:5173";
248
+ if (/remix/.test(command))
249
+ return "http://localhost:3000";
250
+ if (/astro/.test(command))
251
+ return "http://localhost:4321";
252
+ if (/angular|ng\s+serve/.test(command))
253
+ return "http://localhost:4200";
254
+ if (/npm\s+run\s+dev|npm\s+start|npx\s+react-scripts/.test(command))
255
+ return "http://localhost:3000";
256
+ return null;
257
+ };
223
258
  const summarizeToolResult = (toolName, data, success) => {
224
259
  if (!success) {
225
- return `${toolName} failed`;
260
+ return { summary: `${toolName} failed` };
226
261
  }
227
262
  const path = typeof data.path === "string" ? data.path : "";
228
263
  const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
229
264
  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
- }
265
+ const result = {
266
+ summary: totalLines !== null ? `Read ${totalLines} lines` : "Read file",
267
+ };
268
+ if (path)
269
+ result.path = path;
270
+ return result;
236
271
  }
237
272
  if (toolName === "list_directory") {
238
- const dir = path || ".";
239
- if (totalLines !== null) {
240
- return `Listed ${totalLines} entries in <${dir}>`;
241
- }
242
- return `Listed <${dir}>`;
273
+ return {
274
+ summary: totalLines !== null ? `Listed ${totalLines} entries` : "Listed directory",
275
+ path: path || ".",
276
+ };
243
277
  }
244
278
  if (toolName === "semantic_search") {
245
- return "Searched codebase context";
279
+ return { summary: "Searched codebase context" };
246
280
  }
247
281
  if (toolName === "search_files") {
248
- return totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths";
282
+ return {
283
+ summary: totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths",
284
+ };
249
285
  }
250
- return `${toolName} completed`;
286
+ return { summary: `${toolName} completed` };
251
287
  };
252
288
  const isLikelyVoiceNoise = (text) => {
253
289
  const normalized = text.trim().toLowerCase();
@@ -442,9 +478,17 @@ export const AstraApp = () => {
442
478
  const welcomeLine = dividerWidth < 96 ? "Welcome to Astra" : FOUNDER_WELCOME;
443
479
  const backend = useMemo(() => new BackendClient(), []);
444
480
  const { exit } = useApp();
481
+ // Keep backend token fresh: when the client auto-refreshes on 401, persist and update state.
482
+ useEffect(() => {
483
+ backend.setOnTokenRefreshed((refreshed) => {
484
+ saveSession(refreshed);
485
+ setUser(refreshed);
486
+ });
487
+ }, [backend]);
445
488
  // In-session file cache: tracks files created/edited so subsequent requests
446
489
  // include their latest content in workspaceFiles (VirtualFS stays up to date).
447
490
  const localFileCache = useRef(new Map());
491
+ const abortRunRef = useRef(null);
448
492
  const writeLocalFile = useCallback((relPath, content, language) => {
449
493
  try {
450
494
  const abs = join(workspaceRoot, relPath);
@@ -483,6 +527,7 @@ export const AstraApp = () => {
483
527
  const [activeModel, setActiveModel] = useState(getDefaultModel());
484
528
  const [creditsRemaining, setCreditsRemaining] = useState(null);
485
529
  const [lastCreditCost, setLastCreditCost] = useState(null);
530
+ const [loopCtx, setLoopCtx] = useState(null);
486
531
  const runtimeMode = getRuntimeMode();
487
532
  const [prompt, setPrompt] = useState("");
488
533
  const [thinking, setThinking] = useState(false);
@@ -494,6 +539,7 @@ export const AstraApp = () => {
494
539
  const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState(null);
495
540
  const [micSetupDevices, setMicSetupDevices] = useState(null);
496
541
  const [toolFeedMode, setToolFeedMode] = useState("compact");
542
+ const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
497
543
  const [historyOpen, setHistoryOpen] = useState(false);
498
544
  const [historyMode, setHistoryMode] = useState("picker");
499
545
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -508,11 +554,11 @@ export const AstraApp = () => {
508
554
  const fileEditBuffersRef = useRef(new Map());
509
555
  const isSuperAdmin = user?.role === "super_admin";
510
556
  const pushMessage = useCallback((kind, text) => {
511
- setMessages((prev) => [...prev, { kind, text }].slice(-300));
557
+ setMessages((prev) => [...prev, { kind, text, ts: Date.now() }].slice(-300));
512
558
  }, []);
513
559
  const pushToolCard = useCallback((card) => {
514
560
  setMessages((prev) => {
515
- const nextEntry = { kind: "tool", text: card.summary, card };
561
+ const nextEntry = { kind: "tool", text: card.summary, card, ts: Date.now() };
516
562
  const last = prev[prev.length - 1];
517
563
  if (last &&
518
564
  last.kind === "tool" &&
@@ -806,6 +852,12 @@ export const AstraApp = () => {
806
852
  exit();
807
853
  return;
808
854
  }
855
+ if (key.escape && thinking) {
856
+ abortRunRef.current?.abort();
857
+ pushMessage("system", "Cancelled.");
858
+ pushMessage("system", "");
859
+ return;
860
+ }
809
861
  if (historyOpen) {
810
862
  if (key.escape) {
811
863
  if (historyMode === "sessions") {
@@ -1122,16 +1174,18 @@ export const AstraApp = () => {
1122
1174
  }
1123
1175
  }
1124
1176
  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.";
1177
+ // Server mode returns preview_url (tunnel). Desktop mode returns a
1178
+ // plain message — try to guess the local URL from the command.
1179
+ const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
1180
+ const command = typeof d.command === "string" ? d.command : "";
1181
+ const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
1182
+ const displayUrl = tunnelUrl ?? localUrl;
1130
1183
  pushToolCard({
1131
1184
  kind: "preview",
1132
1185
  toolName: "start_preview",
1133
1186
  locality,
1134
- summary: message
1187
+ summary: "Dev server running",
1188
+ ...(displayUrl ? { path: displayUrl } : {}),
1135
1189
  });
1136
1190
  return null;
1137
1191
  }
@@ -1176,6 +1230,16 @@ export const AstraApp = () => {
1176
1230
  pushMessage("system", "");
1177
1231
  }
1178
1232
  }
1233
+ if (event.type === "timing") {
1234
+ const ev = event;
1235
+ if (ev.phase === "llm_done") {
1236
+ const inTok = Number(ev.input_tokens ?? 0);
1237
+ const outTok = Number(ev.output_tokens ?? 0);
1238
+ if (inTok > 0 || outTok > 0)
1239
+ setLoopCtx({ in: inTok, out: outTok });
1240
+ }
1241
+ return null;
1242
+ }
1179
1243
  if (event.type === "continuation_check") {
1180
1244
  const recommendation = typeof event.recommendation === "string" && event.recommendation
1181
1245
  ? event.recommendation
@@ -1213,12 +1277,13 @@ export const AstraApp = () => {
1213
1277
  if (alreadyRepresented) {
1214
1278
  return null;
1215
1279
  }
1216
- const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
1280
+ const { summary, path: summaryPath } = summarizeToolResult(toolName, payload, Boolean(event.success));
1217
1281
  pushToolCard({
1218
1282
  kind: event.success ? "success" : "error",
1219
1283
  toolName,
1220
1284
  locality: payload.local === true ? "LOCAL" : "REMOTE",
1221
- summary: event.success ? summary : `${toolName} ${mark}`
1285
+ summary: event.success ? summary : `${toolName} ${mark}`,
1286
+ ...(summaryPath ? { path: summaryPath } : {}),
1222
1287
  });
1223
1288
  }
1224
1289
  }
@@ -1230,12 +1295,13 @@ export const AstraApp = () => {
1230
1295
  }
1231
1296
  }
1232
1297
  return null;
1233
- }, [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]);
1298
+ }, [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]);
1234
1299
  const sendPrompt = useCallback(async (rawPrompt) => {
1235
1300
  const text = rawPrompt.trim();
1236
1301
  if (!text || !user || thinking) {
1237
1302
  return;
1238
1303
  }
1304
+ setPrompt("");
1239
1305
  // Mic onboarding: intercept when waiting for device selection.
1240
1306
  if (micSetupDevices !== null) {
1241
1307
  const idx = parseInt(text, 10);
@@ -1258,17 +1324,29 @@ export const AstraApp = () => {
1258
1324
  return;
1259
1325
  }
1260
1326
  if (text === "/help") {
1261
- pushMessage("system", "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit");
1327
+ pushMessage("system", isSuperAdmin
1328
+ ? "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
1329
+ : "/new /history /voice /dictate on|off|status /settings /settings model <id> /logout /exit");
1262
1330
  pushMessage("system", "");
1263
1331
  return;
1264
1332
  }
1265
1333
  if (text === "/tools compact") {
1334
+ if (!isSuperAdmin) {
1335
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1336
+ pushMessage("system", "");
1337
+ return;
1338
+ }
1266
1339
  setToolFeedMode("compact");
1267
1340
  pushMessage("system", "Tool feed set to compact.");
1268
1341
  pushMessage("system", "");
1269
1342
  return;
1270
1343
  }
1271
1344
  if (text === "/tools expanded") {
1345
+ if (!isSuperAdmin) {
1346
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1347
+ pushMessage("system", "");
1348
+ return;
1349
+ }
1272
1350
  setToolFeedMode("expanded");
1273
1351
  pushMessage("system", "Tool feed set to expanded.");
1274
1352
  pushMessage("system", "");
@@ -1367,6 +1445,9 @@ export const AstraApp = () => {
1367
1445
  pushMessage("user", text);
1368
1446
  setThinking(true);
1369
1447
  setStreamingText("");
1448
+ setLoopCtx(null);
1449
+ const abortController = new AbortController();
1450
+ abortRunRef.current = abortController;
1370
1451
  try {
1371
1452
  // Scan the local workspace so the backend VirtualFS is populated.
1372
1453
  // Merge in any files created/edited during this session so edits
@@ -1375,8 +1456,25 @@ export const AstraApp = () => {
1375
1456
  const sessionFiles = Array.from(localFileCache.current.values());
1376
1457
  const seenPaths = new Set(sessionFiles.map((f) => f.path));
1377
1458
  const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
1378
- let assistant = "";
1459
+ // `pendingText` is text received since the last committed block.
1460
+ // It gets flushed to the messages list whenever tool activity starts,
1461
+ // keeping text and tool cards in the exact order they were emitted.
1462
+ let pendingText = "";
1463
+ let allAssistantText = "";
1379
1464
  let localActionConfirmed = false;
1465
+ const commitPending = (applyGuard = false) => {
1466
+ if (!pendingText.trim()) {
1467
+ pendingText = "";
1468
+ return;
1469
+ }
1470
+ const clean = normalizeAssistantText(pendingText);
1471
+ const msg = applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
1472
+ ? `Remote result (not yet confirmed as local filesystem change): ${clean}`
1473
+ : clean;
1474
+ pushMessage("assistant", msg);
1475
+ pendingText = "";
1476
+ setStreamingText("");
1477
+ };
1380
1478
  for await (const event of backend.streamChat({
1381
1479
  user,
1382
1480
  sessionId: activeSessionId,
@@ -1384,7 +1482,8 @@ export const AstraApp = () => {
1384
1482
  workspaceRoot,
1385
1483
  workspaceTree,
1386
1484
  workspaceFiles: mergedFiles,
1387
- model: activeModel
1485
+ model: activeModel,
1486
+ signal: abortController.signal
1388
1487
  })) {
1389
1488
  if (event.type === "run_in_terminal") {
1390
1489
  localActionConfirmed = true;
@@ -1398,30 +1497,37 @@ export const AstraApp = () => {
1398
1497
  if (event.type === "done") {
1399
1498
  break;
1400
1499
  }
1500
+ // Flush any accumulated text before the first tool event so that text
1501
+ // appears above the tool cards that follow it — preserving order.
1502
+ if (event.type === "tool_start" || event.type === "run_in_terminal") {
1503
+ commitPending();
1504
+ }
1401
1505
  const piece = await handleEvent(event, activeSessionId);
1402
1506
  if (piece) {
1403
- assistant += piece;
1404
- setStreamingText(normalizeAssistantText(assistant));
1507
+ pendingText += piece;
1508
+ allAssistantText += piece;
1509
+ setStreamingText(normalizeAssistantText(pendingText));
1405
1510
  }
1406
1511
  }
1407
1512
  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 }]);
1513
+ commitPending(true);
1514
+ // Update conversation history for the backend with the full combined text.
1515
+ if (allAssistantText.trim()) {
1516
+ setChatMessages((prev) => [...prev, { role: "assistant", content: normalizeAssistantText(allAssistantText) }]);
1415
1517
  }
1416
1518
  else {
1417
- setChatMessages((prev) => [...prev, { role: "assistant", content: assistant }]);
1519
+ setChatMessages((prev) => [...prev, { role: "assistant", content: allAssistantText }]);
1418
1520
  }
1419
1521
  pushMessage("system", "");
1420
1522
  }
1421
1523
  catch (error) {
1422
- pushMessage("error", `Error: ${error instanceof Error ? error.message : String(error)}`);
1524
+ // AbortError fires when user cancels don't show as an error
1525
+ if (error instanceof Error && error.name !== "AbortError") {
1526
+ pushMessage("error", `Error: ${error.message}`);
1527
+ }
1423
1528
  }
1424
1529
  finally {
1530
+ abortRunRef.current = null;
1425
1531
  setThinking(false);
1426
1532
  }
1427
1533
  }, [
@@ -1447,6 +1553,17 @@ export const AstraApp = () => {
1447
1553
  voiceWaitingForSilence,
1448
1554
  workspaceRoot
1449
1555
  ]);
1556
+ const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
1557
+ useEffect(() => {
1558
+ if (!thinking) {
1559
+ setThinkingColorIdx(0);
1560
+ return;
1561
+ }
1562
+ const interval = setInterval(() => {
1563
+ setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
1564
+ }, 120);
1565
+ return () => clearInterval(interval);
1566
+ }, [thinking]);
1450
1567
  useEffect(() => {
1451
1568
  if (!voiceQueuedPrompt || !user || thinking) {
1452
1569
  return;
@@ -1509,7 +1626,9 @@ export const AstraApp = () => {
1509
1626
  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
1627
  }) })), _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
1628
  }
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
1629
+ 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
1630
+ ? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
1631
+ : ""}` }), _jsx(Text, { color: "#6c88a8", children: `scope ${workspaceRoot.length > HEADER_PATH_MAX ? `…${workspaceRoot.slice(-(HEADER_PATH_MAX - 1))}` : workspaceRoot} · voice ${voiceEnabled
1513
1632
  ? voicePreparing
1514
1633
  ? "on/preparing"
1515
1634
  : voiceListening
@@ -1517,7 +1636,7 @@ export const AstraApp = () => {
1517
1636
  ? "on/waiting"
1518
1637
  : "on/listening"
1519
1638
  : "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) => {
1639
+ : "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
1640
  const prev = index > 0 ? messages[index - 1] : null;
1522
1641
  const style = styleForKind(message.kind);
1523
1642
  const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
@@ -1527,11 +1646,21 @@ export const AstraApp = () => {
1527
1646
  message.kind !== "system" &&
1528
1647
  prev?.kind !== message.kind &&
1529
1648
  (message.kind === "assistant" || message.kind === "tool");
1649
+ // Show a subtle turn separator before each assistant response that
1650
+ // follows a tool block — makes it easy to see where one turn ends.
1651
+ const needsTurnSeparator = message.kind === "assistant" &&
1652
+ Boolean(prev) &&
1653
+ prev?.kind === "tool";
1530
1654
  if (isSpacing) {
1531
1655
  return _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: " " }) }, `${index}-spacer`);
1532
1656
  }
1533
1657
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1534
1658
  const card = message.card;
1659
+ // In compact mode, hide "start" spinner cards — they create noise
1660
+ // (one per tool call) without adding signal after the run completes.
1661
+ if (toolFeedMode === "compact" && card.kind === "start") {
1662
+ return null;
1663
+ }
1535
1664
  const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1536
1665
  const icon = card.kind === "error"
1537
1666
  ? "✕"
@@ -1559,11 +1688,16 @@ export const AstraApp = () => {
1559
1688
  : card.kind === "preview"
1560
1689
  ? "#9ad5ff"
1561
1690
  : "#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}`));
1691
+ // Timestamps fade with age: bright for recent, dim for older
1692
+ const tsAge = message.ts ? Date.now() - message.ts : Infinity;
1693
+ const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
1694
+ 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
1695
  }
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("");
1696
+ const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
1697
+ const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
1698
+ const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
1699
+ 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}`));
1700
+ }), 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
1701
  void sendPrompt(value);
1568
1702
  }, onChange: (value) => {
1569
1703
  if (!voiceListening) {