@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/src/app/App.tsx CHANGED
@@ -25,6 +25,7 @@ type UiMessage = {
25
25
  kind: "system" | "user" | "assistant" | "tool" | "error";
26
26
  text: string;
27
27
  card?: ToolCard;
28
+ ts?: number;
28
29
  };
29
30
 
30
31
  type HistoryMode = "picker" | "sessions";
@@ -198,6 +199,15 @@ const extractAssistantText = (event: AgentEvent): string | null => {
198
199
 
199
200
  const LABEL_WIDTH = 10;
200
201
  const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
202
+
203
+ const formatTime = (ts: number): string => {
204
+ const d = new Date(ts);
205
+ const raw = d.getHours();
206
+ const ampm = raw >= 12 ? "pm" : "am";
207
+ const h = raw % 12 === 0 ? 12 : raw % 12;
208
+ const m = d.getMinutes().toString().padStart(2, "0");
209
+ return `${h}:${m} ${ampm}`;
210
+ };
201
211
  const MIN_DIVIDER = 64;
202
212
  const MAX_DIVIDER = 120;
203
213
 
@@ -248,7 +258,13 @@ const normalizeAssistantText = (input: string): string => {
248
258
  // Remove control chars but preserve newlines/tabs.
249
259
  .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
250
260
  .replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
251
- .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);
@@ -662,6 +708,7 @@ export const AstraApp = (): React.JSX.Element => {
662
708
  const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
663
709
  const [micSetupDevices, setMicSetupDevices] = useState<Array<{index: number; name: string}> | null>(null);
664
710
  const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
711
+ const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
665
712
  const [historyOpen, setHistoryOpen] = useState(false);
666
713
  const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
667
714
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -677,12 +724,12 @@ export const AstraApp = (): React.JSX.Element => {
677
724
  const isSuperAdmin = user?.role === "super_admin";
678
725
 
679
726
  const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
680
- setMessages((prev) => [...prev, {kind, text}].slice(-300));
727
+ setMessages((prev) => [...prev, {kind, text, ts: Date.now()}].slice(-300));
681
728
  }, []);
682
729
 
683
730
  const pushToolCard = useCallback((card: ToolCard) => {
684
731
  setMessages((prev) => {
685
- const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
732
+ const nextEntry = {kind: "tool", text: card.summary, card, ts: Date.now()} satisfies UiMessage;
686
733
  const last = prev[prev.length - 1];
687
734
  if (
688
735
  last &&
@@ -991,6 +1038,13 @@ export const AstraApp = (): React.JSX.Element => {
991
1038
  return;
992
1039
  }
993
1040
 
1041
+ if (key.escape && thinking) {
1042
+ abortRunRef.current?.abort();
1043
+ pushMessage("system", "Cancelled.");
1044
+ pushMessage("system", "");
1045
+ return;
1046
+ }
1047
+
994
1048
  if (historyOpen) {
995
1049
  if (key.escape) {
996
1050
  if (historyMode === "sessions") {
@@ -1323,17 +1377,18 @@ export const AstraApp = (): React.JSX.Element => {
1323
1377
  }
1324
1378
 
1325
1379
  if (!isSuperAdmin && event.tool_name === "start_preview") {
1326
- 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.";
1380
+ // Server mode returns preview_url (tunnel). Desktop mode returns a
1381
+ // plain message try to guess the local URL from the command.
1382
+ const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
1383
+ const command = typeof d.command === "string" ? d.command : "";
1384
+ const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
1385
+ const displayUrl = tunnelUrl ?? localUrl;
1332
1386
  pushToolCard({
1333
1387
  kind: "preview",
1334
1388
  toolName: "start_preview",
1335
1389
  locality,
1336
- summary: message
1390
+ summary: "Dev server running",
1391
+ ...(displayUrl ? {path: displayUrl} : {}),
1337
1392
  });
1338
1393
  return null;
1339
1394
  }
@@ -1383,6 +1438,16 @@ export const AstraApp = (): React.JSX.Element => {
1383
1438
  }
1384
1439
  }
1385
1440
 
1441
+ if (event.type === "timing") {
1442
+ const ev = event as Record<string, unknown>;
1443
+ if (ev.phase === "llm_done") {
1444
+ const inTok = Number(ev.input_tokens ?? 0);
1445
+ const outTok = Number(ev.output_tokens ?? 0);
1446
+ if (inTok > 0 || outTok > 0) setLoopCtx({in: inTok, out: outTok});
1447
+ }
1448
+ return null;
1449
+ }
1450
+
1386
1451
  if (event.type === "continuation_check") {
1387
1452
  const recommendation =
1388
1453
  typeof event.recommendation === "string" && event.recommendation
@@ -1426,12 +1491,13 @@ export const AstraApp = (): React.JSX.Element => {
1426
1491
  if (alreadyRepresented) {
1427
1492
  return null;
1428
1493
  }
1429
- const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
1494
+ const {summary, path: summaryPath} = summarizeToolResult(toolName, payload, Boolean(event.success));
1430
1495
  pushToolCard({
1431
1496
  kind: event.success ? "success" : "error",
1432
1497
  toolName,
1433
1498
  locality: payload.local === true ? "LOCAL" : "REMOTE",
1434
- summary: event.success ? summary : `${toolName} ${mark}`
1499
+ summary: event.success ? summary : `${toolName} ${mark}`,
1500
+ ...(summaryPath ? {path: summaryPath} : {}),
1435
1501
  });
1436
1502
  }
1437
1503
  }
@@ -1443,7 +1509,7 @@ export const AstraApp = (): React.JSX.Element => {
1443
1509
  }
1444
1510
  return null;
1445
1511
  },
1446
- [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
1512
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]
1447
1513
  );
1448
1514
 
1449
1515
  const sendPrompt = useCallback(
@@ -1452,6 +1518,7 @@ export const AstraApp = (): React.JSX.Element => {
1452
1518
  if (!text || !user || thinking) {
1453
1519
  return;
1454
1520
  }
1521
+ setPrompt("");
1455
1522
 
1456
1523
  // Mic onboarding: intercept when waiting for device selection.
1457
1524
  if (micSetupDevices !== null) {
@@ -1478,18 +1545,30 @@ export const AstraApp = (): React.JSX.Element => {
1478
1545
  if (text === "/help") {
1479
1546
  pushMessage(
1480
1547
  "system",
1481
- "/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"
1482
1551
  );
1483
1552
  pushMessage("system", "");
1484
1553
  return;
1485
1554
  }
1486
1555
  if (text === "/tools compact") {
1556
+ if (!isSuperAdmin) {
1557
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1558
+ pushMessage("system", "");
1559
+ return;
1560
+ }
1487
1561
  setToolFeedMode("compact");
1488
1562
  pushMessage("system", "Tool feed set to compact.");
1489
1563
  pushMessage("system", "");
1490
1564
  return;
1491
1565
  }
1492
1566
  if (text === "/tools expanded") {
1567
+ if (!isSuperAdmin) {
1568
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1569
+ pushMessage("system", "");
1570
+ return;
1571
+ }
1493
1572
  setToolFeedMode("expanded");
1494
1573
  pushMessage("system", "Tool feed set to expanded.");
1495
1574
  pushMessage("system", "");
@@ -1605,6 +1684,10 @@ export const AstraApp = (): React.JSX.Element => {
1605
1684
  pushMessage("user", text);
1606
1685
  setThinking(true);
1607
1686
  setStreamingText("");
1687
+ setLoopCtx(null);
1688
+
1689
+ const abortController = new AbortController();
1690
+ abortRunRef.current = abortController;
1608
1691
 
1609
1692
  try {
1610
1693
  // Scan the local workspace so the backend VirtualFS is populated.
@@ -1615,8 +1698,28 @@ export const AstraApp = (): React.JSX.Element => {
1615
1698
  const seenPaths = new Set(sessionFiles.map((f) => f.path));
1616
1699
  const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
1617
1700
 
1618
- 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 = "";
1619
1706
  let localActionConfirmed = false;
1707
+
1708
+ const commitPending = (applyGuard = false) => {
1709
+ if (!pendingText.trim()) {
1710
+ pendingText = "";
1711
+ return;
1712
+ }
1713
+ const clean = normalizeAssistantText(pendingText);
1714
+ const msg =
1715
+ applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
1716
+ ? `Remote result (not yet confirmed as local filesystem change): ${clean}`
1717
+ : clean;
1718
+ pushMessage("assistant", msg);
1719
+ pendingText = "";
1720
+ setStreamingText("");
1721
+ };
1722
+
1620
1723
  for await (const event of backend.streamChat({
1621
1724
  user,
1622
1725
  sessionId: activeSessionId,
@@ -1624,7 +1727,8 @@ export const AstraApp = (): React.JSX.Element => {
1624
1727
  workspaceRoot,
1625
1728
  workspaceTree,
1626
1729
  workspaceFiles: mergedFiles,
1627
- model: activeModel
1730
+ model: activeModel,
1731
+ signal: abortController.signal
1628
1732
  })) {
1629
1733
  if (event.type === "run_in_terminal") {
1630
1734
  localActionConfirmed = true;
@@ -1638,29 +1742,35 @@ export const AstraApp = (): React.JSX.Element => {
1638
1742
  if (event.type === "done") {
1639
1743
  break;
1640
1744
  }
1745
+ // Flush any accumulated text before the first tool event so that text
1746
+ // appears above the tool cards that follow it — preserving order.
1747
+ if (event.type === "tool_start" || event.type === "run_in_terminal") {
1748
+ commitPending();
1749
+ }
1641
1750
  const piece = await handleEvent(event, activeSessionId);
1642
1751
  if (piece) {
1643
- assistant += piece;
1644
- setStreamingText(normalizeAssistantText(assistant));
1752
+ pendingText += piece;
1753
+ allAssistantText += piece;
1754
+ setStreamingText(normalizeAssistantText(pendingText));
1645
1755
  }
1646
1756
  }
1647
1757
 
1648
1758
  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}]);
1759
+ commitPending(true);
1760
+ // Update conversation history for the backend with the full combined text.
1761
+ if (allAssistantText.trim()) {
1762
+ setChatMessages((prev) => [...prev, {role: "assistant", content: normalizeAssistantText(allAssistantText)}]);
1657
1763
  } else {
1658
- setChatMessages((prev) => [...prev, {role: "assistant", content: assistant}]);
1764
+ setChatMessages((prev) => [...prev, {role: "assistant", content: allAssistantText}]);
1659
1765
  }
1660
1766
  pushMessage("system", "");
1661
1767
  } catch (error) {
1662
- 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
+ }
1663
1772
  } finally {
1773
+ abortRunRef.current = null;
1664
1774
  setThinking(false);
1665
1775
  }
1666
1776
  },
@@ -1689,6 +1799,18 @@ export const AstraApp = (): React.JSX.Element => {
1689
1799
  ]
1690
1800
  );
1691
1801
 
1802
+ const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
1803
+ useEffect(() => {
1804
+ if (!thinking) {
1805
+ setThinkingColorIdx(0);
1806
+ return;
1807
+ }
1808
+ const interval = setInterval(() => {
1809
+ setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
1810
+ }, 120);
1811
+ return () => clearInterval(interval);
1812
+ }, [thinking]);
1813
+
1692
1814
  useEffect(() => {
1693
1815
  if (!voiceQueuedPrompt || !user || thinking) {
1694
1816
  return;
@@ -1920,7 +2042,11 @@ export const AstraApp = (): React.JSX.Element => {
1920
2042
  <Text color="#7a9bba">
1921
2043
  {`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
1922
2044
  creditsRemaining ?? "--"
1923
- }${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}`}
2045
+ }${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}${
2046
+ loopCtx
2047
+ ? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
2048
+ : ""
2049
+ }`}
1924
2050
  </Text>
1925
2051
  <Text color="#6c88a8">
1926
2052
  {`scope ${
@@ -1939,7 +2065,7 @@ export const AstraApp = (): React.JSX.Element => {
1939
2065
  </Text>
1940
2066
  <Text color="#2a3a50">{divider}</Text>
1941
2067
  <Text color="#3a5068">/help /new /history /voice /dictate on|off|status</Text>
1942
- <Text color="#3a5068">/tools compact|expanded /settings /logout /exit</Text>
2068
+ <Text color="#3a5068">{isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit"}</Text>
1943
2069
  <Text color="#2a3a50">{divider}</Text>
1944
2070
  <Box flexDirection="column" marginTop={1}>
1945
2071
  {messages.map((message, index) => {
@@ -1953,12 +2079,23 @@ export const AstraApp = (): React.JSX.Element => {
1953
2079
  message.kind !== "system" &&
1954
2080
  prev?.kind !== message.kind &&
1955
2081
  (message.kind === "assistant" || message.kind === "tool");
2082
+ // Show a subtle turn separator before each assistant response that
2083
+ // follows a tool block — makes it easy to see where one turn ends.
2084
+ const needsTurnSeparator =
2085
+ message.kind === "assistant" &&
2086
+ Boolean(prev) &&
2087
+ prev?.kind === "tool";
1956
2088
  if (isSpacing) {
1957
2089
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1958
2090
  }
1959
2091
 
1960
2092
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1961
2093
  const card = message.card;
2094
+ // In compact mode, hide "start" spinner cards — they create noise
2095
+ // (one per tool call) without adding signal after the run completes.
2096
+ if (toolFeedMode === "compact" && card.kind === "start") {
2097
+ return null;
2098
+ }
1962
2099
  const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1963
2100
  const icon =
1964
2101
  card.kind === "error"
@@ -1988,49 +2125,64 @@ export const AstraApp = (): React.JSX.Element => {
1988
2125
  : card.kind === "preview"
1989
2126
  ? "#9ad5ff"
1990
2127
  : "#9bc5ff";
2128
+ // Timestamps fade with age: bright for recent, dim for older
2129
+ const tsAge = message.ts ? Date.now() - message.ts : Infinity;
2130
+ const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
1991
2131
  return (
1992
2132
  <React.Fragment key={`${index}-${message.kind}`}>
1993
- {needsGroupGap ? <Text> </Text> : null}
2133
+ {needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
1994
2134
  <Box flexDirection="row">
1995
2135
  <Text color={style.labelColor}>{paddedLabel}</Text>
1996
- <Box flexDirection="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}
2136
+ <Box flexDirection="row" flexGrow={1}>
2137
+ <Box flexGrow={1} flexDirection="column">
2138
+ <Text color={accent}>
2139
+ {icon} {card.summary}
2140
+ {card.path ? <Text color="#ffffff"> {card.path}</Text> : null}
2141
+ {card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
2142
+ <Text color="#5a7a9a"> · {localityLabel}</Text>
2143
+ </Text>
2144
+ {toolFeedMode === "expanded" ? (
2145
+ <>
2146
+ {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
2147
+ {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
2148
+ <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
2149
+ {line}
2150
+ </Text>
2151
+ ))}
2152
+ </>
2153
+ ) : null}
2154
+ </Box>
2155
+ {message.ts ? <Text color={tsColor}> {formatTime(message.ts)}</Text> : null}
2012
2156
  </Box>
2013
2157
  </Box>
2014
2158
  </React.Fragment>
2015
2159
  );
2016
2160
  }
2017
2161
 
2162
+ const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
2163
+ const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
2164
+ const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
2018
2165
  return (
2019
2166
  <React.Fragment key={`${index}-${message.kind}`}>
2020
- {needsGroupGap ? <Text> </Text> : null}
2167
+ {needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
2021
2168
  <Box flexDirection="row">
2022
2169
  <Text color={style.labelColor} bold={style.bold}>
2023
2170
  {paddedLabel}
2024
2171
  </Text>
2025
- {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
- )}
2172
+ <Box flexDirection="row" flexGrow={1}>
2173
+ {message.kind === "assistant" ? (
2174
+ <Box flexGrow={1} flexDirection="column">
2175
+ {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
2176
+ </Box>
2177
+ ) : (
2178
+ <Box flexGrow={1}>
2179
+ <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
2180
+ {message.text}
2181
+ </Text>
2182
+ </Box>
2183
+ )}
2184
+ {showTs ? <Text color={tsColor2}> {formatTime(message.ts as number)}</Text> : null}
2185
+ </Box>
2034
2186
  </Box>
2035
2187
  </React.Fragment>
2036
2188
  );
@@ -2043,12 +2195,13 @@ export const AstraApp = (): React.JSX.Element => {
2043
2195
  ) : null}
2044
2196
  </Box>
2045
2197
  <Text color="#2a3a50">{divider}</Text>
2046
- {thinking ? (
2198
+ {thinking && !streamingText ? (
2047
2199
  <Box flexDirection="row" marginTop={1}>
2048
2200
  <Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
2049
- <Text color="#6080a0">
2201
+ <Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}>
2050
2202
  <Spinner type="dots2" />
2051
- <Text color="#8aa2c9"> thinking...</Text>
2203
+ <Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}> Working...</Text>
2204
+ <Text color="#2a4060"> esc to cancel</Text>
2052
2205
  </Text>
2053
2206
  </Box>
2054
2207
  ) : null}
@@ -2071,7 +2224,6 @@ export const AstraApp = (): React.JSX.Element => {
2071
2224
  <TextInput
2072
2225
  value={prompt}
2073
2226
  onSubmit={(value) => {
2074
- setPrompt("");
2075
2227
  void sendPrompt(value);
2076
2228
  }}
2077
2229
  onChange={(value) => {