@astra-code/astra-ai 0.1.2 → 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
  }
@@ -934,6 +1046,8 @@ export const AstraApp = (): React.JSX.Element => {
934
1046
  }
935
1047
 
936
1048
  const authSession = data as AuthSession;
1049
+ // Set token immediately so follow-up profile/session calls are authenticated.
1050
+ backend.setAuthSession(authSession);
937
1051
  const hydrated = await backend.getUserProfile(authSession).catch(() => authSession);
938
1052
  saveSession(hydrated);
939
1053
  backend.setAuthSession(hydrated);
@@ -1195,6 +1309,26 @@ export const AstraApp = (): React.JSX.Element => {
1195
1309
 
1196
1310
  if (event.type === "credits_exhausted") {
1197
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;
1198
1332
  }
1199
1333
 
1200
1334
  if (!isSuperAdmin && NOISY_EVENT_TYPES.has(event.type)) {
@@ -1202,14 +1336,6 @@ export const AstraApp = (): React.JSX.Element => {
1202
1336
  }
1203
1337
 
1204
1338
  if (!isSuperAdmin && event.type === "tool_start") {
1205
- const tool = event.tool as {name?: string} | undefined;
1206
- const name = tool?.name ?? "tool";
1207
- pushToolCard({
1208
- kind: "start",
1209
- toolName: name,
1210
- locality: "REMOTE",
1211
- summary: `${name} is running...`
1212
- });
1213
1339
  return null;
1214
1340
  }
1215
1341
 
@@ -1223,11 +1349,23 @@ export const AstraApp = (): React.JSX.Element => {
1223
1349
  } else if (event.type === "tool_result") {
1224
1350
  const mark = event.success ? "completed" : "failed";
1225
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));
1226
1364
  pushToolCard({
1227
1365
  kind: event.success ? "success" : "error",
1228
1366
  toolName,
1229
- locality: ((event.data ?? {}) as Record<string, unknown>).local === true ? "LOCAL" : "REMOTE",
1230
- summary: `${toolName} ${mark}`
1367
+ locality: payload.local === true ? "LOCAL" : "REMOTE",
1368
+ summary: event.success ? summary : `${toolName} ${mark}`
1231
1369
  });
1232
1370
  }
1233
1371
  }
@@ -1239,7 +1377,7 @@ export const AstraApp = (): React.JSX.Element => {
1239
1377
  }
1240
1378
  return null;
1241
1379
  },
1242
- [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, writeLocalFile]
1380
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
1243
1381
  );
1244
1382
 
1245
1383
  const sendPrompt = useCallback(
@@ -1252,20 +1390,32 @@ export const AstraApp = (): React.JSX.Element => {
1252
1390
  if (text === "/help") {
1253
1391
  pushMessage(
1254
1392
  "system",
1255
- "/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"
1256
1394
  );
1257
1395
  pushMessage("system", "");
1258
1396
  return;
1259
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
+ }
1260
1410
  if (text === "/settings") {
1261
1411
  pushMessage(
1262
1412
  "system",
1263
- `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()}`
1264
1414
  );
1265
1415
  pushMessage("system", "");
1266
1416
  return;
1267
1417
  }
1268
- if (text === "/voice") {
1418
+ if (text === "/dictate" || text === "/voice") {
1269
1419
  if (!voiceEnabled) {
1270
1420
  setVoiceEnabled(true);
1271
1421
  startLiveVoice(true);
@@ -1273,7 +1423,7 @@ export const AstraApp = (): React.JSX.Element => {
1273
1423
  }
1274
1424
  pushMessage(
1275
1425
  "system",
1276
- `Voice input is on${voiceListening ? " (currently listening)" : ""}. Use /voice off to disable.`
1426
+ `Dictation is on${voiceListening ? " (currently listening)" : ""}. Use /dictate off to disable.`
1277
1427
  );
1278
1428
  pushMessage("system", "");
1279
1429
  return;
@@ -1298,34 +1448,34 @@ export const AstraApp = (): React.JSX.Element => {
1298
1448
  await openHistory();
1299
1449
  return;
1300
1450
  }
1301
- if (text === "/voice status") {
1451
+ if (text === "/dictate status" || text === "/voice status") {
1302
1452
  pushMessage(
1303
1453
  "system",
1304
- `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)" : ""}.`
1305
1455
  );
1306
1456
  pushMessage("system", "");
1307
1457
  return;
1308
1458
  }
1309
- if (text === "/voice on") {
1459
+ if (text === "/dictate on" || text === "/voice on") {
1310
1460
  setVoiceEnabled(true);
1311
1461
  startLiveVoice(true);
1312
- 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.`);
1313
1463
  pushMessage("system", "");
1314
1464
  return;
1315
1465
  }
1316
- if (text === "/voice off") {
1466
+ if (text === "/dictate off" || text === "/voice off") {
1317
1467
  await stopLiveVoice();
1318
1468
  setVoiceEnabled(false);
1319
- pushMessage("system", "Voice input disabled.");
1469
+ pushMessage("system", "Dictation disabled.");
1320
1470
  pushMessage("system", "");
1321
1471
  return;
1322
1472
  }
1323
- if (text === "/voice input") {
1473
+ if (text === "/dictate input" || text === "/voice input") {
1324
1474
  const transcribed = await transcribeOnce();
1325
1475
  if (!transcribed) {
1326
1476
  pushMessage(
1327
1477
  "error",
1328
- "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)."
1329
1479
  );
1330
1480
  return;
1331
1481
  }
@@ -1397,6 +1547,9 @@ export const AstraApp = (): React.JSX.Element => {
1397
1547
  localActionConfirmed = true;
1398
1548
  }
1399
1549
  }
1550
+ if (event.type === "done") {
1551
+ break;
1552
+ }
1400
1553
  const piece = await handleEvent(event, activeSessionId);
1401
1554
  if (piece) {
1402
1555
  assistant += piece;
@@ -1436,9 +1589,11 @@ export const AstraApp = (): React.JSX.Element => {
1436
1589
  startLiveVoice,
1437
1590
  stopLiveVoice,
1438
1591
  thinking,
1592
+ toolFeedMode,
1439
1593
  user,
1440
1594
  voiceEnabled,
1441
1595
  voiceListening,
1596
+ voicePreparing,
1442
1597
  voiceWaitingForSilence,
1443
1598
  workspaceRoot
1444
1599
  ]
@@ -1453,7 +1608,28 @@ export const AstraApp = (): React.JSX.Element => {
1453
1608
  if (!queued) {
1454
1609
  return;
1455
1610
  }
1456
- 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}`);
1457
1633
  setPrompt("");
1458
1634
  void sendPrompt(queued);
1459
1635
  }, [pushMessage, sendPrompt, thinking, user, voiceQueuedPrompt]);
@@ -1498,8 +1674,8 @@ export const AstraApp = (): React.JSX.Element => {
1498
1674
  if (booting) {
1499
1675
  return (
1500
1676
  <Box flexDirection="column">
1501
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1502
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1677
+ <Text color="#7aa2ff">{brand}</Text>
1678
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1503
1679
  <Text color="#8aa2c9">
1504
1680
  <Spinner type="dots12" /> Booting Astra terminal shell...
1505
1681
  </Text>
@@ -1510,8 +1686,8 @@ export const AstraApp = (): React.JSX.Element => {
1510
1686
  if (bootError) {
1511
1687
  return (
1512
1688
  <Box flexDirection="column">
1513
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1514
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1689
+ <Text color="#7aa2ff">{brand}</Text>
1690
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1515
1691
  <Text color="red">{bootError}</Text>
1516
1692
  </Box>
1517
1693
  );
@@ -1520,8 +1696,8 @@ export const AstraApp = (): React.JSX.Element => {
1520
1696
  if (!user) {
1521
1697
  return (
1522
1698
  <Box flexDirection="column">
1523
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1524
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1699
+ <Text color="#7aa2ff">{brand}</Text>
1700
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1525
1701
  <Text color="#b8c8ff">
1526
1702
  Astra terminal AI pair programmer ({loginMode === "login" ? "Sign in" : "Create account"})
1527
1703
  </Text>
@@ -1565,16 +1741,16 @@ export const AstraApp = (): React.JSX.Element => {
1565
1741
  const selected = filteredHistory[historyIndex];
1566
1742
  return (
1567
1743
  <Box flexDirection="column">
1568
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1569
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1570
- <Text color="#2a3a50">{DIVIDER}</Text>
1744
+ <Text color="#7aa2ff">{brand}</Text>
1745
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1746
+ <Text color="#2a3a50">{divider}</Text>
1571
1747
  {historyMode === "picker" ? (
1572
1748
  <Box flexDirection="column">
1573
1749
  <Box flexDirection="row" justifyContent="space-between">
1574
1750
  <Text color="#dce9ff">History Picker</Text>
1575
1751
  <Text color="#5a7a9a">Esc close · Enter select</Text>
1576
1752
  </Box>
1577
- <Text color="#2a3a50">{DIVIDER}</Text>
1753
+ <Text color="#2a3a50">{divider}</Text>
1578
1754
  <Box marginTop={1} flexDirection="column">
1579
1755
  <Box flexDirection="row">
1580
1756
  <Text color={historyPickerIndex === 0 ? "#dce9ff" : "#7a9bba"}>
@@ -1587,7 +1763,7 @@ export const AstraApp = (): React.JSX.Element => {
1587
1763
  </Text>
1588
1764
  </Box>
1589
1765
  </Box>
1590
- <Text color="#2a3a50">{DIVIDER}</Text>
1766
+ <Text color="#2a3a50">{divider}</Text>
1591
1767
  <Text color="#5a7a9a">Credit usage history opens: {HISTORY_SETTINGS_URL}</Text>
1592
1768
  </Box>
1593
1769
  ) : (
@@ -1600,7 +1776,7 @@ export const AstraApp = (): React.JSX.Element => {
1600
1776
  <Text color="#4a6070">search </Text>
1601
1777
  <TextInput value={historyQuery} onChange={setHistoryQuery} placeholder="Filter chats..." />
1602
1778
  </Box>
1603
- <Text color="#2a3a50">{DIVIDER}</Text>
1779
+ <Text color="#2a3a50">{divider}</Text>
1604
1780
  {historyLoading ? (
1605
1781
  <Text color="#8aa2c9">
1606
1782
  <Spinner type="dots12" /> Loading chat history...
@@ -1626,7 +1802,7 @@ export const AstraApp = (): React.JSX.Element => {
1626
1802
  })}
1627
1803
  </Box>
1628
1804
  )}
1629
- <Text color="#2a3a50">{DIVIDER}</Text>
1805
+ <Text color="#2a3a50">{divider}</Text>
1630
1806
  <Box flexDirection="row" justifyContent="space-between">
1631
1807
  <Text color="#5a7a9a">
1632
1808
  Page {historyPage + 1} / {historyPageCount}
@@ -1641,57 +1817,53 @@ export const AstraApp = (): React.JSX.Element => {
1641
1817
 
1642
1818
  return (
1643
1819
  <Box flexDirection="column">
1644
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1645
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1820
+ <Text color="#7aa2ff">{brand}</Text>
1821
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1646
1822
  {/*<Text color="#9aa8c1">Astra Code · {process.cwd()}</Text>*/}
1647
- <Text color="#2a3a50">{DIVIDER}</Text>
1648
- <Box flexDirection="row" gap={2}>
1649
- <Box flexDirection="row">
1650
- <Text color="#4a6070">mode </Text>
1651
- <Text color="#9ad5ff">{runtimeMode}</Text>
1652
- </Box>
1653
- <Box flexDirection="row">
1654
- <Text color="#4a6070">scope </Text>
1655
- <Text color="#7a9bba">{workspaceRoot}</Text>
1656
- </Box>
1657
- <Box flexDirection="row">
1658
- <Text color="#4a6070">provider </Text>
1659
- <Text color="#9ad5ff">{getProviderForModel(activeModel)}</Text>
1660
- </Box>
1661
- <Box flexDirection="row">
1662
- <Text color="#4a6070">credits </Text>
1663
- <Text color={creditsRemaining !== null && creditsRemaining < 50 ? "#ffaa55" : "#9ad5ff"}>
1664
- {creditsRemaining ?? "--"}
1665
- {lastCreditCost !== null ? (
1666
- <Text color="#5a7a9a"> (-{lastCreditCost})</Text>
1667
- ) : null}
1668
- </Text>
1669
- </Box>
1670
- <Box flexDirection="row">
1671
- <Text color="#4a6070">model </Text>
1672
- <Text color="#9ad5ff">{activeModel}</Text>
1673
- </Box>
1674
- <Box flexDirection="row">
1675
- <Text color="#4a6070">voice </Text>
1676
- <Text color={voiceEnabled ? "#9ad5ff" : "#5a7a9a"}>
1677
- {voiceEnabled ? (voiceListening ? (voiceWaitingForSilence ? "on/waiting" : "on/listening") : "on") : "off"}
1678
- </Text>
1679
- </Box>
1680
- </Box>
1681
- <Text color="#2a3a50">{DIVIDER}</Text>
1682
- <Text color="#3a5068">/help /new /history /voice /voice on|off|status /settings /logout /exit</Text>
1683
- <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>
1684
1848
  <Box flexDirection="column" marginTop={1}>
1685
1849
  {messages.map((message, index) => {
1850
+ const prev = index > 0 ? messages[index - 1] : null;
1686
1851
  const style = styleForKind(message.kind);
1687
1852
  const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
1688
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");
1689
1860
  if (isSpacing) {
1690
1861
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1691
1862
  }
1692
1863
 
1693
1864
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1694
1865
  const card = message.card;
1866
+ const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1695
1867
  const icon =
1696
1868
  card.kind === "error"
1697
1869
  ? "✕"
@@ -1721,38 +1893,50 @@ export const AstraApp = (): React.JSX.Element => {
1721
1893
  ? "#9ad5ff"
1722
1894
  : "#9bc5ff";
1723
1895
  return (
1724
- <Box key={`${index}-${message.kind}`} flexDirection="row">
1725
- <Text color={style.labelColor}>{paddedLabel}</Text>
1726
- <Box flexDirection="column">
1727
- <Text color={accent}>
1728
- {icon} {card.summary} <Text color="#5a7a9a">[{card.locality}]</Text>
1729
- </Text>
1730
- {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
1731
- {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
1732
- <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
1733
- {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>
1734
1905
  </Text>
1735
- ))}
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>
1736
1917
  </Box>
1737
- </Box>
1918
+ </React.Fragment>
1738
1919
  );
1739
1920
  }
1740
1921
 
1741
1922
  return (
1742
- <Box key={`${index}-${message.kind}`} flexDirection="row">
1743
- <Text color={style.labelColor} bold={style.bold}>
1744
- {paddedLabel}
1745
- </Text>
1746
- {message.kind === "assistant" ? (
1747
- <Box flexDirection="column">
1748
- {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
1749
- </Box>
1750
- ) : (
1751
- <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
1752
- {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}
1753
1928
  </Text>
1754
- )}
1755
- </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>
1756
1940
  );
1757
1941
  })}
1758
1942
  {streamingText ? (
@@ -1762,7 +1946,7 @@ export const AstraApp = (): React.JSX.Element => {
1762
1946
  </Box>
1763
1947
  ) : null}
1764
1948
  </Box>
1765
- <Text color="#2a3a50">{DIVIDER}</Text>
1949
+ <Text color="#2a3a50">{divider}</Text>
1766
1950
  {thinking ? (
1767
1951
  <Box flexDirection="row" marginTop={1}>
1768
1952
  <Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
@@ -1774,13 +1958,15 @@ export const AstraApp = (): React.JSX.Element => {
1774
1958
  ) : null}
1775
1959
  {voiceEnabled && !thinking ? (
1776
1960
  <Box flexDirection="row" marginTop={1}>
1777
- <Text color="#9ad5ff">{"🎤 voice".padEnd(LABEL_WIDTH, " ")}</Text>
1778
- {voiceListening && !voiceWaitingForSilence ? (
1779
- <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>
1780
1966
  ) : voiceWaitingForSilence ? (
1781
- <Text color="#b7c4d8">⏸ waiting for silence... dramatic pause loading...</Text>
1967
+ <Text color="#b7c4d8">⏳ speech detected - waiting for silence to send</Text>
1782
1968
  ) : (
1783
- <Text color="#6f8199">voice armed... say something when ready</Text>
1969
+ <Text color="#6f8199">⚪ voice armed - preparing next listen window</Text>
1784
1970
  )}
1785
1971
  </Box>
1786
1972
  ) : null}
@@ -1797,7 +1983,7 @@ export const AstraApp = (): React.JSX.Element => {
1797
1983
  setPrompt(value);
1798
1984
  }
1799
1985
  }}
1800
- 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..."}
1801
1987
  />
1802
1988
  </Box>
1803
1989
  </Box>