@astra-code/astra-ai 0.1.3 → 0.1.4

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
@@ -34,6 +34,7 @@ type ToolCard = {
34
34
  toolName: string;
35
35
  locality: "LOCAL" | "REMOTE";
36
36
  summary: string;
37
+ count?: number;
37
38
  path?: string;
38
39
  snippetLines?: string[];
39
40
  };
@@ -58,6 +59,7 @@ const ASTRA_ASCII = `
58
59
  ### ### ######## ### ### ### ### ### ######## ######## ######### ##########
59
60
  by Sean Donovan
60
61
  `;
62
+ const ASTRA_COMPACT = "ASTRA CODE";
61
63
 
62
64
  const WELCOME_WIDTH = 96;
63
65
 
@@ -74,6 +76,9 @@ const FOUNDER_WELCOME = centerLine("Welcome to Astra from Astra CEO & Founder, S
74
76
 
75
77
  const HISTORY_SETTINGS_URL = "https://astra-web-builder.vercel.app/settings";
76
78
  const VOICE_SILENCE_MS = Number(process.env.ASTRA_VOICE_SILENCE_MS ?? "3000");
79
+ const VOICE_MIN_CHARS = Number(process.env.ASTRA_VOICE_MIN_CHARS ?? "4");
80
+ const VOICE_DUPLICATE_WINDOW_MS = Number(process.env.ASTRA_VOICE_DUPLICATE_WINDOW_MS ?? "10000");
81
+ const VOICE_NOISE_WORDS = new Set(["you", "yes", "yeah", "yep", "ok", "okay", "uh", "um", "hmm"]);
77
82
  const TOOL_SNIPPET_LINES = 6;
78
83
  const NOISY_EVENT_TYPES = new Set([
79
84
  "timing",
@@ -190,10 +195,10 @@ const extractAssistantText = (event: AgentEvent): string | null => {
190
195
  return null;
191
196
  };
192
197
 
193
- const DIVIDER =
194
- "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
195
-
196
198
  const LABEL_WIDTH = 10;
199
+ const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
200
+ const MIN_DIVIDER = 64;
201
+ const MAX_DIVIDER = 120;
197
202
 
198
203
  type MessageStyle = {
199
204
  label: string;
@@ -238,14 +243,67 @@ const normalizeAssistantText = (input: string): string => {
238
243
  return "";
239
244
  }
240
245
 
241
- return input
246
+ let out = input
242
247
  // Remove control chars but preserve newlines/tabs.
243
248
  .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
249
+ .replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
250
+ .replace(/([.!?])([A-Za-z])/g, "$1 $2")
251
+ .replace(/([a-z])(\u2022)/g, "$1\n$2")
252
+ .replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
253
+ .replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
254
+ .replace(/(\bnpm audit)(All tasks complete\.)/gi, "$1\n\n$2")
255
+ .replace(/([.!?])\s*(Summary:|Next:|Tests:)/g, "$1\n\n$2")
256
+ .replace(/([^\n])(\u2022\s)/g, "$1\n$2")
244
257
  // Trim trailing spaces line-by-line.
245
258
  .replace(/[ \t]+$/gm, "")
246
259
  // Normalize excessive blank lines.
247
- .replace(/\n{3,}/g, "\n\n")
248
- .trim();
260
+ .replace(/\n{3,}/g, "\n\n");
261
+
262
+ const paragraphs = out
263
+ .split(/\n{2,}/)
264
+ .map((p) => p.trim())
265
+ .filter(Boolean);
266
+ const seen = new Set<string>();
267
+ const deduped: string[] = [];
268
+ for (const para of paragraphs) {
269
+ const key = para.toLowerCase().replace(/\s+/g, " ");
270
+ if (para.length > 50 && seen.has(key)) {
271
+ continue;
272
+ }
273
+ seen.add(key);
274
+ deduped.push(para);
275
+ }
276
+ return deduped.join("\n\n").trim();
277
+ };
278
+
279
+ const summarizeToolResult = (toolName: string, data: Record<string, unknown>, success: boolean): string => {
280
+ if (!success) {
281
+ return `${toolName} failed`;
282
+ }
283
+ const path = typeof data.path === "string" ? data.path : "";
284
+ const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
285
+ if (toolName === "view_file") {
286
+ if (totalLines !== null && path) {
287
+ return `Read ${totalLines} lines of <${path}>`;
288
+ }
289
+ if (path) {
290
+ return `Read <${path}>`;
291
+ }
292
+ }
293
+ if (toolName === "list_directory") {
294
+ const dir = path || ".";
295
+ if (totalLines !== null) {
296
+ return `Listed ${totalLines} entries in <${dir}>`;
297
+ }
298
+ return `Listed <${dir}>`;
299
+ }
300
+ if (toolName === "semantic_search") {
301
+ return "Searched codebase context";
302
+ }
303
+ if (toolName === "search_files") {
304
+ return totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths";
305
+ }
306
+ return `${toolName} completed`;
249
307
  };
250
308
 
251
309
  type InlineToken = {text: string; bold?: boolean; italic?: boolean; code?: boolean};
@@ -507,6 +565,20 @@ const renderMarkdownContent = (text: string, baseColor: string, keyPrefix: strin
507
565
 
508
566
  export const AstraApp = (): React.JSX.Element => {
509
567
  const workspaceRoot = useMemo(() => process.cwd(), []);
568
+ const [terminalWidth, setTerminalWidth] = useState<number>(() => process.stdout.columns || MAX_DIVIDER);
569
+ useEffect(() => {
570
+ const updateWidth = (): void => {
571
+ setTerminalWidth(process.stdout.columns || MAX_DIVIDER);
572
+ };
573
+ process.stdout.on("resize", updateWidth);
574
+ return () => {
575
+ process.stdout.off("resize", updateWidth);
576
+ };
577
+ }, []);
578
+ const dividerWidth = Math.max(MIN_DIVIDER, Math.min(MAX_DIVIDER, (terminalWidth ?? MAX_DIVIDER) - 2));
579
+ const divider = "─".repeat(dividerWidth);
580
+ const brand = dividerWidth < 96 ? ASTRA_COMPACT : ASTRA_ASCII;
581
+ const welcomeLine = dividerWidth < 96 ? "Welcome to Astra" : FOUNDER_WELCOME;
510
582
  const backend = useMemo(() => new BackendClient(), []);
511
583
  const {exit} = useApp();
512
584
 
@@ -565,8 +637,10 @@ export const AstraApp = (): React.JSX.Element => {
565
637
  const [streamingText, setStreamingText] = useState("");
566
638
  const [voiceEnabled, setVoiceEnabled] = useState(false);
567
639
  const [voiceListening, setVoiceListening] = useState(false);
640
+ const [voicePreparing, setVoicePreparing] = useState(false);
568
641
  const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
569
642
  const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
643
+ const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
570
644
  const [historyOpen, setHistoryOpen] = useState(false);
571
645
  const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
572
646
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -576,6 +650,8 @@ export const AstraApp = (): React.JSX.Element => {
576
650
  const [historyIndex, setHistoryIndex] = useState(0);
577
651
  const liveVoiceRef = useRef<LiveTranscriptionController | null>(null);
578
652
  const voiceSilenceTimerRef = useRef<NodeJS.Timeout | null>(null);
653
+ const lastVoicePromptRef = useRef<{text: string; at: number} | null>(null);
654
+ const lastIgnoredVoiceRef = useRef<{text: string; at: number} | null>(null);
579
655
  const fileEditBuffersRef = useRef<Map<string, {path: string; chunks: string[]; toolName: string | undefined}>>(new Map());
580
656
  const isSuperAdmin = user?.role === "super_admin";
581
657
 
@@ -584,7 +660,28 @@ export const AstraApp = (): React.JSX.Element => {
584
660
  }, []);
585
661
 
586
662
  const pushToolCard = useCallback((card: ToolCard) => {
587
- setMessages((prev) => [...prev, {kind: "tool", text: card.summary, card} satisfies UiMessage].slice(-300));
663
+ setMessages((prev) => {
664
+ const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
665
+ const last = prev[prev.length - 1];
666
+ if (
667
+ last &&
668
+ last.kind === "tool" &&
669
+ last.card &&
670
+ last.card.toolName === card.toolName &&
671
+ last.card.kind === card.kind &&
672
+ last.card.summary === card.summary &&
673
+ last.card.locality === card.locality
674
+ ) {
675
+ const updated = [...prev];
676
+ const existingCount = Math.max(1, Number(last.card.count ?? 1));
677
+ updated[updated.length - 1] = {
678
+ ...last,
679
+ card: {...last.card, count: existingCount + 1}
680
+ };
681
+ return updated.slice(-300);
682
+ }
683
+ return [...prev, nextEntry].slice(-300);
684
+ });
588
685
  }, []);
589
686
 
590
687
  const filteredHistory = useMemo(() => {
@@ -659,11 +756,13 @@ export const AstraApp = (): React.JSX.Element => {
659
756
  }
660
757
  const controller = liveVoiceRef.current;
661
758
  if (!controller) {
759
+ setVoicePreparing(false);
662
760
  setVoiceWaitingForSilence(false);
663
761
  return;
664
762
  }
665
763
  liveVoiceRef.current = null;
666
764
  await controller.stop();
765
+ setVoicePreparing(false);
667
766
  setVoiceListening(false);
668
767
  setVoiceWaitingForSilence(false);
669
768
  }, []);
@@ -673,19 +772,29 @@ export const AstraApp = (): React.JSX.Element => {
673
772
  return;
674
773
  }
675
774
  setVoiceEnabled(true);
676
- setVoiceListening(true);
775
+ setVoicePreparing(true);
776
+ setVoiceListening(false);
677
777
  setVoiceWaitingForSilence(false);
678
778
  if (announce) {
679
- pushMessage("system", "Voice input started. Speak now…");
779
+ pushMessage("system", "Dictation armed. Preparing microphone...");
680
780
  }
681
781
  liveVoiceRef.current = startLiveTranscription({
682
782
  onPartial: (text) => {
783
+ setVoicePreparing(false);
784
+ setVoiceListening(true);
683
785
  setPrompt(text);
684
786
  if (voiceSilenceTimerRef.current) {
685
787
  clearTimeout(voiceSilenceTimerRef.current);
686
788
  }
687
789
  const candidate = text.trim();
688
790
  if (!candidate) {
791
+ setVoiceWaitingForSilence(false);
792
+ return;
793
+ }
794
+ const normalized = candidate.toLowerCase();
795
+ const isLikelyNoise = normalized.length < VOICE_MIN_CHARS || VOICE_NOISE_WORDS.has(normalized);
796
+ if (isLikelyNoise) {
797
+ setVoiceWaitingForSilence(false);
689
798
  return;
690
799
  }
691
800
  setVoiceWaitingForSilence(true);
@@ -701,10 +810,13 @@ export const AstraApp = (): React.JSX.Element => {
701
810
  }
702
811
  setPrompt(text);
703
812
  liveVoiceRef.current = null;
813
+ setVoicePreparing(false);
704
814
  setVoiceListening(false);
705
815
  setVoiceWaitingForSilence(false);
706
816
  },
707
817
  onError: (error) => {
818
+ setVoicePreparing(false);
819
+ setVoiceListening(false);
708
820
  setVoiceWaitingForSilence(false);
709
821
  pushMessage("error", `Voice transcription error: ${error.message}`);
710
822
  }
@@ -1197,6 +1309,26 @@ export const AstraApp = (): React.JSX.Element => {
1197
1309
 
1198
1310
  if (event.type === "credits_exhausted") {
1199
1311
  setCreditsRemaining(0);
1312
+ if (voiceEnabled) {
1313
+ await stopLiveVoice();
1314
+ setVoiceEnabled(false);
1315
+ pushMessage("system", "Dictation paused: credits exhausted. Recharge, then run /dictate on.");
1316
+ pushMessage("system", "");
1317
+ }
1318
+ }
1319
+
1320
+ if (event.type === "continuation_check") {
1321
+ const recommendation =
1322
+ typeof event.recommendation === "string" && event.recommendation
1323
+ ? event.recommendation
1324
+ : "Please narrow the scope and continue with a specific target.";
1325
+ const streak = Number(event.consecutive_read_only_iterations ?? 0);
1326
+ const threshold = Number(event.threshold ?? 0);
1327
+ pushMessage(
1328
+ "system",
1329
+ `Exploration paused (${streak}/${threshold} read-only turns). ${recommendation}`
1330
+ );
1331
+ return null;
1200
1332
  }
1201
1333
 
1202
1334
  if (!isSuperAdmin && NOISY_EVENT_TYPES.has(event.type)) {
@@ -1204,14 +1336,6 @@ export const AstraApp = (): React.JSX.Element => {
1204
1336
  }
1205
1337
 
1206
1338
  if (!isSuperAdmin && event.type === "tool_start") {
1207
- const tool = event.tool as {name?: string} | undefined;
1208
- const name = tool?.name ?? "tool";
1209
- pushToolCard({
1210
- kind: "start",
1211
- toolName: name,
1212
- locality: "REMOTE",
1213
- summary: `${name} is running...`
1214
- });
1215
1339
  return null;
1216
1340
  }
1217
1341
 
@@ -1225,11 +1349,23 @@ export const AstraApp = (): React.JSX.Element => {
1225
1349
  } else if (event.type === "tool_result") {
1226
1350
  const mark = event.success ? "completed" : "failed";
1227
1351
  const toolName = typeof event.tool_name === "string" ? event.tool_name : "tool";
1352
+ const payload = (event.data ?? {}) as Record<string, unknown>;
1353
+ const resultType = typeof event.result_type === "string" ? event.result_type : "";
1354
+ const alreadyRepresented =
1355
+ resultType === "file_create" ||
1356
+ resultType === "file_edit" ||
1357
+ resultType === "file_delete" ||
1358
+ toolName === "start_preview" ||
1359
+ toolName === "capture_screenshot";
1360
+ if (alreadyRepresented) {
1361
+ return null;
1362
+ }
1363
+ const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
1228
1364
  pushToolCard({
1229
1365
  kind: event.success ? "success" : "error",
1230
1366
  toolName,
1231
- locality: ((event.data ?? {}) as Record<string, unknown>).local === true ? "LOCAL" : "REMOTE",
1232
- summary: `${toolName} ${mark}`
1367
+ locality: payload.local === true ? "LOCAL" : "REMOTE",
1368
+ summary: event.success ? summary : `${toolName} ${mark}`
1233
1369
  });
1234
1370
  }
1235
1371
  }
@@ -1241,7 +1377,7 @@ export const AstraApp = (): React.JSX.Element => {
1241
1377
  }
1242
1378
  return null;
1243
1379
  },
1244
- [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, writeLocalFile]
1380
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
1245
1381
  );
1246
1382
 
1247
1383
  const sendPrompt = useCallback(
@@ -1254,20 +1390,32 @@ export const AstraApp = (): React.JSX.Element => {
1254
1390
  if (text === "/help") {
1255
1391
  pushMessage(
1256
1392
  "system",
1257
- "/new /history /voice /voice on|off|status /settings /settings model <id> /logout /exit"
1393
+ "/new /history /dictate /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
1258
1394
  );
1259
1395
  pushMessage("system", "");
1260
1396
  return;
1261
1397
  }
1398
+ if (text === "/tools compact") {
1399
+ setToolFeedMode("compact");
1400
+ pushMessage("system", "Tool feed set to compact.");
1401
+ pushMessage("system", "");
1402
+ return;
1403
+ }
1404
+ if (text === "/tools expanded") {
1405
+ setToolFeedMode("expanded");
1406
+ pushMessage("system", "Tool feed set to expanded.");
1407
+ pushMessage("system", "");
1408
+ return;
1409
+ }
1262
1410
  if (text === "/settings") {
1263
1411
  pushMessage(
1264
1412
  "system",
1265
- `Settings: mode=${runtimeMode} scope=${workspaceRoot} model=${activeModel} provider=${getProviderForModel(activeModel)} voice=${voiceEnabled ? "on" : "off"} listening=${voiceListening ? "yes" : "no"} silence_ms=${VOICE_SILENCE_MS} role=${user.role ?? "user"} client_id=${getDefaultClientId()} backend=${getBackendUrl()}`
1413
+ `Settings: mode=${runtimeMode} scope=${workspaceRoot} model=${activeModel} provider=${getProviderForModel(activeModel)} dictate=${voiceEnabled ? "on" : "off"} listening=${voiceListening ? "yes" : "no"} tool_feed=${toolFeedMode} silence_ms=${VOICE_SILENCE_MS} role=${user.role ?? "user"} client_id=${getDefaultClientId()} backend=${getBackendUrl()}`
1266
1414
  );
1267
1415
  pushMessage("system", "");
1268
1416
  return;
1269
1417
  }
1270
- if (text === "/voice") {
1418
+ if (text === "/dictate" || text === "/voice") {
1271
1419
  if (!voiceEnabled) {
1272
1420
  setVoiceEnabled(true);
1273
1421
  startLiveVoice(true);
@@ -1275,7 +1423,7 @@ export const AstraApp = (): React.JSX.Element => {
1275
1423
  }
1276
1424
  pushMessage(
1277
1425
  "system",
1278
- `Voice input is on${voiceListening ? " (currently listening)" : ""}. Use /voice off to disable.`
1426
+ `Dictation is on${voiceListening ? " (currently listening)" : ""}. Use /dictate off to disable.`
1279
1427
  );
1280
1428
  pushMessage("system", "");
1281
1429
  return;
@@ -1300,34 +1448,34 @@ export const AstraApp = (): React.JSX.Element => {
1300
1448
  await openHistory();
1301
1449
  return;
1302
1450
  }
1303
- if (text === "/voice status") {
1451
+ if (text === "/dictate status" || text === "/voice status") {
1304
1452
  pushMessage(
1305
1453
  "system",
1306
- `Voice input is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}${voiceWaitingForSilence ? " (waiting for silence to auto-send)" : ""}.`
1454
+ `Dictation is ${voiceEnabled ? "on" : "off"}${voicePreparing ? " (preparing mic)" : ""}${voiceListening ? " (listening)" : ""}${voiceWaitingForSilence ? " (waiting for silence to auto-send)" : ""}.`
1307
1455
  );
1308
1456
  pushMessage("system", "");
1309
1457
  return;
1310
1458
  }
1311
- if (text === "/voice on") {
1459
+ if (text === "/dictate on" || text === "/voice on") {
1312
1460
  setVoiceEnabled(true);
1313
1461
  startLiveVoice(true);
1314
- pushMessage("system", `Voice input enabled. Auto-send after ${Math.round(VOICE_SILENCE_MS / 1000)}s silence.`);
1462
+ pushMessage("system", `Dictation enabled. Auto-send after ${Math.round(VOICE_SILENCE_MS / 1000)}s silence.`);
1315
1463
  pushMessage("system", "");
1316
1464
  return;
1317
1465
  }
1318
- if (text === "/voice off") {
1466
+ if (text === "/dictate off" || text === "/voice off") {
1319
1467
  await stopLiveVoice();
1320
1468
  setVoiceEnabled(false);
1321
- pushMessage("system", "Voice input disabled.");
1469
+ pushMessage("system", "Dictation disabled.");
1322
1470
  pushMessage("system", "");
1323
1471
  return;
1324
1472
  }
1325
- if (text === "/voice input") {
1473
+ if (text === "/dictate input" || text === "/voice input") {
1326
1474
  const transcribed = await transcribeOnce();
1327
1475
  if (!transcribed) {
1328
1476
  pushMessage(
1329
1477
  "error",
1330
- "No speech transcribed. Ensure you're signed in and that mic capture works (optional ASTRA_STT_CAPTURE_COMMAND)."
1478
+ "No speech transcribed. Ensure you're signed in and your microphone capture works (optional ASTRA_STT_CAPTURE_COMMAND)."
1331
1479
  );
1332
1480
  return;
1333
1481
  }
@@ -1399,6 +1547,9 @@ export const AstraApp = (): React.JSX.Element => {
1399
1547
  localActionConfirmed = true;
1400
1548
  }
1401
1549
  }
1550
+ if (event.type === "done") {
1551
+ break;
1552
+ }
1402
1553
  const piece = await handleEvent(event, activeSessionId);
1403
1554
  if (piece) {
1404
1555
  assistant += piece;
@@ -1438,9 +1589,11 @@ export const AstraApp = (): React.JSX.Element => {
1438
1589
  startLiveVoice,
1439
1590
  stopLiveVoice,
1440
1591
  thinking,
1592
+ toolFeedMode,
1441
1593
  user,
1442
1594
  voiceEnabled,
1443
1595
  voiceListening,
1596
+ voicePreparing,
1444
1597
  voiceWaitingForSilence,
1445
1598
  workspaceRoot
1446
1599
  ]
@@ -1455,7 +1608,28 @@ export const AstraApp = (): React.JSX.Element => {
1455
1608
  if (!queued) {
1456
1609
  return;
1457
1610
  }
1458
- pushMessage("system", `Voice input: ${queued}`);
1611
+ const normalizedQueued = queued.toLowerCase();
1612
+ const last = lastVoicePromptRef.current;
1613
+ const isLikelyNoise = normalizedQueued.length < VOICE_MIN_CHARS || VOICE_NOISE_WORDS.has(normalizedQueued);
1614
+ const isFastDuplicate =
1615
+ last !== null &&
1616
+ last.text === normalizedQueued &&
1617
+ Date.now() - last.at <= VOICE_DUPLICATE_WINDOW_MS;
1618
+ if (isLikelyNoise || isFastDuplicate) {
1619
+ const now = Date.now();
1620
+ const lastIgnored = lastIgnoredVoiceRef.current;
1621
+ const shouldLogIgnored =
1622
+ !lastIgnored ||
1623
+ lastIgnored.text !== normalizedQueued ||
1624
+ now - lastIgnored.at > VOICE_DUPLICATE_WINDOW_MS;
1625
+ if (shouldLogIgnored) {
1626
+ pushMessage("system", `Ignored likely-noise dictation input: "${queued}"`);
1627
+ lastIgnoredVoiceRef.current = {text: normalizedQueued, at: now};
1628
+ }
1629
+ return;
1630
+ }
1631
+ lastVoicePromptRef.current = {text: normalizedQueued, at: Date.now()};
1632
+ pushMessage("system", `Dictation input: ${queued}`);
1459
1633
  setPrompt("");
1460
1634
  void sendPrompt(queued);
1461
1635
  }, [pushMessage, sendPrompt, thinking, user, voiceQueuedPrompt]);
@@ -1500,8 +1674,8 @@ export const AstraApp = (): React.JSX.Element => {
1500
1674
  if (booting) {
1501
1675
  return (
1502
1676
  <Box flexDirection="column">
1503
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1504
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1677
+ <Text color="#7aa2ff">{brand}</Text>
1678
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1505
1679
  <Text color="#8aa2c9">
1506
1680
  <Spinner type="dots12" /> Booting Astra terminal shell...
1507
1681
  </Text>
@@ -1512,8 +1686,8 @@ export const AstraApp = (): React.JSX.Element => {
1512
1686
  if (bootError) {
1513
1687
  return (
1514
1688
  <Box flexDirection="column">
1515
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1516
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1689
+ <Text color="#7aa2ff">{brand}</Text>
1690
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1517
1691
  <Text color="red">{bootError}</Text>
1518
1692
  </Box>
1519
1693
  );
@@ -1522,8 +1696,8 @@ export const AstraApp = (): React.JSX.Element => {
1522
1696
  if (!user) {
1523
1697
  return (
1524
1698
  <Box flexDirection="column">
1525
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1526
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1699
+ <Text color="#7aa2ff">{brand}</Text>
1700
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1527
1701
  <Text color="#b8c8ff">
1528
1702
  Astra terminal AI pair programmer ({loginMode === "login" ? "Sign in" : "Create account"})
1529
1703
  </Text>
@@ -1567,16 +1741,16 @@ export const AstraApp = (): React.JSX.Element => {
1567
1741
  const selected = filteredHistory[historyIndex];
1568
1742
  return (
1569
1743
  <Box flexDirection="column">
1570
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1571
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1572
- <Text color="#2a3a50">{DIVIDER}</Text>
1744
+ <Text color="#7aa2ff">{brand}</Text>
1745
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1746
+ <Text color="#2a3a50">{divider}</Text>
1573
1747
  {historyMode === "picker" ? (
1574
1748
  <Box flexDirection="column">
1575
1749
  <Box flexDirection="row" justifyContent="space-between">
1576
1750
  <Text color="#dce9ff">History Picker</Text>
1577
1751
  <Text color="#5a7a9a">Esc close · Enter select</Text>
1578
1752
  </Box>
1579
- <Text color="#2a3a50">{DIVIDER}</Text>
1753
+ <Text color="#2a3a50">{divider}</Text>
1580
1754
  <Box marginTop={1} flexDirection="column">
1581
1755
  <Box flexDirection="row">
1582
1756
  <Text color={historyPickerIndex === 0 ? "#dce9ff" : "#7a9bba"}>
@@ -1589,7 +1763,7 @@ export const AstraApp = (): React.JSX.Element => {
1589
1763
  </Text>
1590
1764
  </Box>
1591
1765
  </Box>
1592
- <Text color="#2a3a50">{DIVIDER}</Text>
1766
+ <Text color="#2a3a50">{divider}</Text>
1593
1767
  <Text color="#5a7a9a">Credit usage history opens: {HISTORY_SETTINGS_URL}</Text>
1594
1768
  </Box>
1595
1769
  ) : (
@@ -1602,7 +1776,7 @@ export const AstraApp = (): React.JSX.Element => {
1602
1776
  <Text color="#4a6070">search </Text>
1603
1777
  <TextInput value={historyQuery} onChange={setHistoryQuery} placeholder="Filter chats..." />
1604
1778
  </Box>
1605
- <Text color="#2a3a50">{DIVIDER}</Text>
1779
+ <Text color="#2a3a50">{divider}</Text>
1606
1780
  {historyLoading ? (
1607
1781
  <Text color="#8aa2c9">
1608
1782
  <Spinner type="dots12" /> Loading chat history...
@@ -1628,7 +1802,7 @@ export const AstraApp = (): React.JSX.Element => {
1628
1802
  })}
1629
1803
  </Box>
1630
1804
  )}
1631
- <Text color="#2a3a50">{DIVIDER}</Text>
1805
+ <Text color="#2a3a50">{divider}</Text>
1632
1806
  <Box flexDirection="row" justifyContent="space-between">
1633
1807
  <Text color="#5a7a9a">
1634
1808
  Page {historyPage + 1} / {historyPageCount}
@@ -1643,57 +1817,53 @@ export const AstraApp = (): React.JSX.Element => {
1643
1817
 
1644
1818
  return (
1645
1819
  <Box flexDirection="column">
1646
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1647
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1820
+ <Text color="#7aa2ff">{brand}</Text>
1821
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1648
1822
  {/*<Text color="#9aa8c1">Astra Code · {process.cwd()}</Text>*/}
1649
- <Text color="#2a3a50">{DIVIDER}</Text>
1650
- <Box flexDirection="row" gap={2}>
1651
- <Box flexDirection="row">
1652
- <Text color="#4a6070">mode </Text>
1653
- <Text color="#9ad5ff">{runtimeMode}</Text>
1654
- </Box>
1655
- <Box flexDirection="row">
1656
- <Text color="#4a6070">scope </Text>
1657
- <Text color="#7a9bba">{workspaceRoot}</Text>
1658
- </Box>
1659
- <Box flexDirection="row">
1660
- <Text color="#4a6070">provider </Text>
1661
- <Text color="#9ad5ff">{getProviderForModel(activeModel)}</Text>
1662
- </Box>
1663
- <Box flexDirection="row">
1664
- <Text color="#4a6070">credits </Text>
1665
- <Text color={creditsRemaining !== null && creditsRemaining < 50 ? "#ffaa55" : "#9ad5ff"}>
1666
- {creditsRemaining ?? "--"}
1667
- {lastCreditCost !== null ? (
1668
- <Text color="#5a7a9a"> (-{lastCreditCost})</Text>
1669
- ) : null}
1670
- </Text>
1671
- </Box>
1672
- <Box flexDirection="row">
1673
- <Text color="#4a6070">model </Text>
1674
- <Text color="#9ad5ff">{activeModel}</Text>
1675
- </Box>
1676
- <Box flexDirection="row">
1677
- <Text color="#4a6070">voice </Text>
1678
- <Text color={voiceEnabled ? "#9ad5ff" : "#5a7a9a"}>
1679
- {voiceEnabled ? (voiceListening ? (voiceWaitingForSilence ? "on/waiting" : "on/listening") : "on") : "off"}
1680
- </Text>
1681
- </Box>
1682
- </Box>
1683
- <Text color="#2a3a50">{DIVIDER}</Text>
1684
- <Text color="#3a5068">/help /new /history /voice /voice on|off|status /settings /logout /exit</Text>
1685
- <Text color="#2a3a50">{DIVIDER}</Text>
1823
+ <Text color="#2a3a50">{divider}</Text>
1824
+ <Text color="#7a9bba">
1825
+ {`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
1826
+ creditsRemaining ?? "--"
1827
+ }${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}`}
1828
+ </Text>
1829
+ <Text color="#6c88a8">
1830
+ {`scope ${
1831
+ workspaceRoot.length > HEADER_PATH_MAX ? `…${workspaceRoot.slice(-(HEADER_PATH_MAX - 1))}` : workspaceRoot
1832
+ } · dictate ${
1833
+ voiceEnabled
1834
+ ? voicePreparing
1835
+ ? "on/preparing"
1836
+ : voiceListening
1837
+ ? voiceWaitingForSilence
1838
+ ? "on/waiting"
1839
+ : "on/listening"
1840
+ : "on/standby"
1841
+ : "off"
1842
+ }`}
1843
+ </Text>
1844
+ <Text color="#2a3a50">{divider}</Text>
1845
+ <Text color="#3a5068">/help /new /history /dictate /dictate on|off|status</Text>
1846
+ <Text color="#3a5068">/tools compact|expanded /settings /logout /exit</Text>
1847
+ <Text color="#2a3a50">{divider}</Text>
1686
1848
  <Box flexDirection="column" marginTop={1}>
1687
1849
  {messages.map((message, index) => {
1850
+ const prev = index > 0 ? messages[index - 1] : null;
1688
1851
  const style = styleForKind(message.kind);
1689
1852
  const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
1690
1853
  const isSpacing = message.text === "" && message.kind === "system";
1854
+ const needsGroupGap =
1855
+ Boolean(prev) &&
1856
+ prev?.kind !== "system" &&
1857
+ message.kind !== "system" &&
1858
+ prev?.kind !== message.kind &&
1859
+ (message.kind === "assistant" || message.kind === "tool");
1691
1860
  if (isSpacing) {
1692
1861
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1693
1862
  }
1694
1863
 
1695
1864
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1696
1865
  const card = message.card;
1866
+ const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1697
1867
  const icon =
1698
1868
  card.kind === "error"
1699
1869
  ? "✕"
@@ -1723,38 +1893,50 @@ export const AstraApp = (): React.JSX.Element => {
1723
1893
  ? "#9ad5ff"
1724
1894
  : "#9bc5ff";
1725
1895
  return (
1726
- <Box key={`${index}-${message.kind}`} flexDirection="row">
1727
- <Text color={style.labelColor}>{paddedLabel}</Text>
1728
- <Box flexDirection="column">
1729
- <Text color={accent}>
1730
- {icon} {card.summary} <Text color="#5a7a9a">[{card.locality}]</Text>
1731
- </Text>
1732
- {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
1733
- {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
1734
- <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
1735
- {line}
1896
+ <React.Fragment key={`${index}-${message.kind}`}>
1897
+ {needsGroupGap ? <Text> </Text> : null}
1898
+ <Box flexDirection="row">
1899
+ <Text color={style.labelColor}>{paddedLabel}</Text>
1900
+ <Box flexDirection="column">
1901
+ <Text color={accent}>
1902
+ {icon} {card.summary}
1903
+ {card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
1904
+ <Text color="#5a7a9a"> · {localityLabel}</Text>
1736
1905
  </Text>
1737
- ))}
1906
+ {toolFeedMode === "expanded" ? (
1907
+ <>
1908
+ {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
1909
+ {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
1910
+ <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
1911
+ {line}
1912
+ </Text>
1913
+ ))}
1914
+ </>
1915
+ ) : null}
1916
+ </Box>
1738
1917
  </Box>
1739
- </Box>
1918
+ </React.Fragment>
1740
1919
  );
1741
1920
  }
1742
1921
 
1743
1922
  return (
1744
- <Box key={`${index}-${message.kind}`} flexDirection="row">
1745
- <Text color={style.labelColor} bold={style.bold}>
1746
- {paddedLabel}
1747
- </Text>
1748
- {message.kind === "assistant" ? (
1749
- <Box flexDirection="column">
1750
- {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
1751
- </Box>
1752
- ) : (
1753
- <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
1754
- {message.text}
1923
+ <React.Fragment key={`${index}-${message.kind}`}>
1924
+ {needsGroupGap ? <Text> </Text> : null}
1925
+ <Box flexDirection="row">
1926
+ <Text color={style.labelColor} bold={style.bold}>
1927
+ {paddedLabel}
1755
1928
  </Text>
1756
- )}
1757
- </Box>
1929
+ {message.kind === "assistant" ? (
1930
+ <Box flexDirection="column">
1931
+ {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
1932
+ </Box>
1933
+ ) : (
1934
+ <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
1935
+ {message.text}
1936
+ </Text>
1937
+ )}
1938
+ </Box>
1939
+ </React.Fragment>
1758
1940
  );
1759
1941
  })}
1760
1942
  {streamingText ? (
@@ -1764,7 +1946,7 @@ export const AstraApp = (): React.JSX.Element => {
1764
1946
  </Box>
1765
1947
  ) : null}
1766
1948
  </Box>
1767
- <Text color="#2a3a50">{DIVIDER}</Text>
1949
+ <Text color="#2a3a50">{divider}</Text>
1768
1950
  {thinking ? (
1769
1951
  <Box flexDirection="row" marginTop={1}>
1770
1952
  <Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
@@ -1776,13 +1958,15 @@ export const AstraApp = (): React.JSX.Element => {
1776
1958
  ) : null}
1777
1959
  {voiceEnabled && !thinking ? (
1778
1960
  <Box flexDirection="row" marginTop={1}>
1779
- <Text color="#9ad5ff">{"🎤 voice".padEnd(LABEL_WIDTH, " ")}</Text>
1780
- {voiceListening && !voiceWaitingForSilence ? (
1781
- <Text color="#a6d9ff">🎙 listening... goblin ears activated 👂</Text>
1961
+ <Text color="#9ad5ff">{"🎤 dictate".padEnd(LABEL_WIDTH, " ")}</Text>
1962
+ {voicePreparing ? (
1963
+ <Text color="#f4d58a">🟡 preparing microphone...</Text>
1964
+ ) : voiceListening && !voiceWaitingForSilence ? (
1965
+ <Text color="#9de3b4">🟢 listening now - speak clearly</Text>
1782
1966
  ) : voiceWaitingForSilence ? (
1783
- <Text color="#b7c4d8">⏸ waiting for silence... dramatic pause loading...</Text>
1967
+ <Text color="#b7c4d8">⏳ speech detected - waiting for silence to send</Text>
1784
1968
  ) : (
1785
- <Text color="#6f8199">voice armed... say something when ready</Text>
1969
+ <Text color="#6f8199">⚪ voice armed - preparing next listen window</Text>
1786
1970
  )}
1787
1971
  </Box>
1788
1972
  ) : null}
@@ -1799,7 +1983,7 @@ export const AstraApp = (): React.JSX.Element => {
1799
1983
  setPrompt(value);
1800
1984
  }
1801
1985
  }}
1802
- placeholder={voiceEnabled ? "Ask Astra... (voice on: auto listen + send on silence)" : "Ask Astra..."}
1986
+ placeholder={voiceEnabled ? "Ask Astra... (dictate on: auto listen + send on silence)" : "Ask Astra..."}
1803
1987
  />
1804
1988
  </Box>
1805
1989
  </Box>