@astra-code/astra-ai 0.1.3 → 0.1.5

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,10 @@ 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
+
80
+ const VOICE_MIN_CHARS = Number(process.env.ASTRA_VOICE_MIN_CHARS ?? "4");
81
+ const VOICE_DUPLICATE_WINDOW_MS = Number(process.env.ASTRA_VOICE_DUPLICATE_WINDOW_MS ?? "10000");
82
+ const VOICE_NOISE_WORDS = new Set(["you", "yes", "yeah", "yep", "ok", "okay", "uh", "um", "hmm"]);
77
83
  const TOOL_SNIPPET_LINES = 6;
78
84
  const NOISY_EVENT_TYPES = new Set([
79
85
  "timing",
@@ -190,10 +196,10 @@ const extractAssistantText = (event: AgentEvent): string | null => {
190
196
  return null;
191
197
  };
192
198
 
193
- const DIVIDER =
194
- "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
195
-
196
199
  const LABEL_WIDTH = 10;
200
+ const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
201
+ const MIN_DIVIDER = 64;
202
+ const MAX_DIVIDER = 120;
197
203
 
198
204
  type MessageStyle = {
199
205
  label: string;
@@ -238,14 +244,86 @@ const normalizeAssistantText = (input: string): string => {
238
244
  return "";
239
245
  }
240
246
 
241
- return input
247
+ let out = input
242
248
  // Remove control chars but preserve newlines/tabs.
243
249
  .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
250
+ .replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
251
+ .replace(/([.!?])([A-Za-z])/g, "$1 $2")
252
+ .replace(/([a-z])(\u2022)/g, "$1\n$2")
253
+ .replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
254
+ .replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
255
+ .replace(/(\bnpm audit)(All tasks complete\.)/gi, "$1\n\n$2")
256
+ .replace(/([.!?])\s*(Summary:|Next:|Tests:)/g, "$1\n\n$2")
257
+ .replace(/([^\n])(\u2022\s)/g, "$1\n$2")
244
258
  // Trim trailing spaces line-by-line.
245
259
  .replace(/[ \t]+$/gm, "")
246
260
  // Normalize excessive blank lines.
247
- .replace(/\n{3,}/g, "\n\n")
248
- .trim();
261
+ .replace(/\n{3,}/g, "\n\n");
262
+
263
+ const paragraphs = out
264
+ .split(/\n{2,}/)
265
+ .map((p) => p.trim())
266
+ .filter(Boolean);
267
+ const seen = new Set<string>();
268
+ const deduped: string[] = [];
269
+ for (const para of paragraphs) {
270
+ const key = para.toLowerCase().replace(/\s+/g, " ");
271
+ if (para.length > 50 && seen.has(key)) {
272
+ continue;
273
+ }
274
+ seen.add(key);
275
+ deduped.push(para);
276
+ }
277
+ return deduped.join("\n\n").trim();
278
+ };
279
+
280
+ const summarizeToolResult = (toolName: string, data: Record<string, unknown>, success: boolean): string => {
281
+ if (!success) {
282
+ return `${toolName} failed`;
283
+ }
284
+ const path = typeof data.path === "string" ? data.path : "";
285
+ const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
286
+ 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
+ }
293
+ }
294
+ if (toolName === "list_directory") {
295
+ const dir = path || ".";
296
+ if (totalLines !== null) {
297
+ return `Listed ${totalLines} entries in <${dir}>`;
298
+ }
299
+ return `Listed <${dir}>`;
300
+ }
301
+ if (toolName === "semantic_search") {
302
+ return "Searched codebase context";
303
+ }
304
+ if (toolName === "search_files") {
305
+ return totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths";
306
+ }
307
+ return `${toolName} completed`;
308
+ };
309
+
310
+ const isLikelyVoiceNoise = (text: string): boolean => {
311
+ const normalized = text.trim().toLowerCase();
312
+ if (!normalized) {
313
+ return true;
314
+ }
315
+ if (normalized.length < VOICE_MIN_CHARS) {
316
+ return true;
317
+ }
318
+ const tokens = normalized.split(/\s+/).filter(Boolean);
319
+ if (tokens.length === 0) {
320
+ return true;
321
+ }
322
+ const nonNoise = tokens.filter((t) => !VOICE_NOISE_WORDS.has(t));
323
+ if (nonNoise.length === 0) {
324
+ return true;
325
+ }
326
+ return false;
249
327
  };
250
328
 
251
329
  type InlineToken = {text: string; bold?: boolean; italic?: boolean; code?: boolean};
@@ -507,6 +585,20 @@ const renderMarkdownContent = (text: string, baseColor: string, keyPrefix: strin
507
585
 
508
586
  export const AstraApp = (): React.JSX.Element => {
509
587
  const workspaceRoot = useMemo(() => process.cwd(), []);
588
+ const [terminalWidth, setTerminalWidth] = useState<number>(() => process.stdout.columns || MAX_DIVIDER);
589
+ useEffect(() => {
590
+ const updateWidth = (): void => {
591
+ setTerminalWidth(process.stdout.columns || MAX_DIVIDER);
592
+ };
593
+ process.stdout.on("resize", updateWidth);
594
+ return () => {
595
+ process.stdout.off("resize", updateWidth);
596
+ };
597
+ }, []);
598
+ const dividerWidth = Math.max(MIN_DIVIDER, Math.min(MAX_DIVIDER, (terminalWidth ?? MAX_DIVIDER) - 2));
599
+ const divider = "─".repeat(dividerWidth);
600
+ const brand = dividerWidth < 96 ? ASTRA_COMPACT : ASTRA_ASCII;
601
+ const welcomeLine = dividerWidth < 96 ? "Welcome to Astra" : FOUNDER_WELCOME;
510
602
  const backend = useMemo(() => new BackendClient(), []);
511
603
  const {exit} = useApp();
512
604
 
@@ -565,8 +657,10 @@ export const AstraApp = (): React.JSX.Element => {
565
657
  const [streamingText, setStreamingText] = useState("");
566
658
  const [voiceEnabled, setVoiceEnabled] = useState(false);
567
659
  const [voiceListening, setVoiceListening] = useState(false);
660
+ const [voicePreparing, setVoicePreparing] = useState(false);
568
661
  const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
569
662
  const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
663
+ const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
570
664
  const [historyOpen, setHistoryOpen] = useState(false);
571
665
  const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
572
666
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -576,6 +670,8 @@ export const AstraApp = (): React.JSX.Element => {
576
670
  const [historyIndex, setHistoryIndex] = useState(0);
577
671
  const liveVoiceRef = useRef<LiveTranscriptionController | null>(null);
578
672
  const voiceSilenceTimerRef = useRef<NodeJS.Timeout | null>(null);
673
+ const lastVoicePromptRef = useRef<{text: string; at: number} | null>(null);
674
+ const lastIgnoredVoiceRef = useRef<{text: string; at: number} | null>(null);
579
675
  const fileEditBuffersRef = useRef<Map<string, {path: string; chunks: string[]; toolName: string | undefined}>>(new Map());
580
676
  const isSuperAdmin = user?.role === "super_admin";
581
677
 
@@ -584,7 +680,28 @@ export const AstraApp = (): React.JSX.Element => {
584
680
  }, []);
585
681
 
586
682
  const pushToolCard = useCallback((card: ToolCard) => {
587
- setMessages((prev) => [...prev, {kind: "tool", text: card.summary, card} satisfies UiMessage].slice(-300));
683
+ setMessages((prev) => {
684
+ const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
685
+ const last = prev[prev.length - 1];
686
+ if (
687
+ last &&
688
+ last.kind === "tool" &&
689
+ last.card &&
690
+ last.card.toolName === card.toolName &&
691
+ last.card.kind === card.kind &&
692
+ last.card.summary === card.summary &&
693
+ last.card.locality === card.locality
694
+ ) {
695
+ const updated = [...prev];
696
+ const existingCount = Math.max(1, Number(last.card.count ?? 1));
697
+ updated[updated.length - 1] = {
698
+ ...last,
699
+ card: {...last.card, count: existingCount + 1}
700
+ };
701
+ return updated.slice(-300);
702
+ }
703
+ return [...prev, nextEntry].slice(-300);
704
+ });
588
705
  }, []);
589
706
 
590
707
  const filteredHistory = useMemo(() => {
@@ -659,11 +776,13 @@ export const AstraApp = (): React.JSX.Element => {
659
776
  }
660
777
  const controller = liveVoiceRef.current;
661
778
  if (!controller) {
779
+ setVoicePreparing(false);
662
780
  setVoiceWaitingForSilence(false);
663
781
  return;
664
782
  }
665
783
  liveVoiceRef.current = null;
666
784
  await controller.stop();
785
+ setVoicePreparing(false);
667
786
  setVoiceListening(false);
668
787
  setVoiceWaitingForSilence(false);
669
788
  }, []);
@@ -672,24 +791,45 @@ export const AstraApp = (): React.JSX.Element => {
672
791
  if (liveVoiceRef.current) {
673
792
  return;
674
793
  }
794
+ // #region agent log
795
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H5',location:'App.tsx:startLiveVoice',message:'startLiveVoice called',data:{announce,voiceEnabled,thinking,hasController:Boolean(liveVoiceRef.current)},timestamp:Date.now()})}).catch(()=>{});
796
+ // #endregion
675
797
  setVoiceEnabled(true);
676
- setVoiceListening(true);
798
+ setVoicePreparing(true);
799
+ setVoiceListening(false);
677
800
  setVoiceWaitingForSilence(false);
678
801
  if (announce) {
679
- pushMessage("system", "Voice input started. Speak now…");
802
+ pushMessage("system", "Voice input armed. Preparing microphone...");
680
803
  }
681
804
  liveVoiceRef.current = startLiveTranscription({
682
805
  onPartial: (text) => {
683
- setPrompt(text);
806
+ setVoicePreparing(false);
807
+ setVoiceListening(true);
684
808
  if (voiceSilenceTimerRef.current) {
685
809
  clearTimeout(voiceSilenceTimerRef.current);
686
810
  }
687
811
  const candidate = text.trim();
688
812
  if (!candidate) {
813
+ setPrompt("");
814
+ setVoiceWaitingForSilence(false);
689
815
  return;
690
816
  }
817
+ const normalized = candidate.toLowerCase();
818
+ const isLikelyNoise = isLikelyVoiceNoise(normalized);
819
+ // #region agent log
820
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H1',location:'App.tsx:startLiveVoice.onPartial',message:'partial transcript observed',data:{textLen:text.length,candidateLen:candidate.length,normalized,isLikelyNoise,silenceMs:VOICE_SILENCE_MS},timestamp:Date.now()})}).catch(()=>{});
821
+ // #endregion
822
+ if (isLikelyNoise) {
823
+ setPrompt("");
824
+ setVoiceWaitingForSilence(false);
825
+ return;
826
+ }
827
+ setPrompt(text);
691
828
  setVoiceWaitingForSilence(true);
692
829
  voiceSilenceTimerRef.current = setTimeout(() => {
830
+ // #region agent log
831
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H2',location:'App.tsx:startLiveVoice.silenceTimeout',message:'silence timeout fired and queueing prompt',data:{candidate,voiceWaitingForSilence:true},timestamp:Date.now()})}).catch(()=>{});
832
+ // #endregion
693
833
  setVoiceQueuedPrompt(candidate);
694
834
  void stopLiveVoice();
695
835
  }, VOICE_SILENCE_MS);
@@ -701,12 +841,21 @@ export const AstraApp = (): React.JSX.Element => {
701
841
  }
702
842
  setPrompt(text);
703
843
  liveVoiceRef.current = null;
844
+ setVoicePreparing(false);
704
845
  setVoiceListening(false);
705
846
  setVoiceWaitingForSilence(false);
847
+ // #region agent log
848
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H4',location:'App.tsx:startLiveVoice.onFinal',message:'final transcript emitted',data:{finalLen:text.length,finalText:text.slice(0,80)},timestamp:Date.now()})}).catch(()=>{});
849
+ // #endregion
706
850
  },
707
851
  onError: (error) => {
852
+ setVoicePreparing(false);
853
+ setVoiceListening(false);
708
854
  setVoiceWaitingForSilence(false);
709
855
  pushMessage("error", `Voice transcription error: ${error.message}`);
856
+ // #region agent log
857
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H4',location:'App.tsx:startLiveVoice.onError',message:'voice transcription error',data:{error:error.message},timestamp:Date.now()})}).catch(()=>{});
858
+ // #endregion
710
859
  }
711
860
  });
712
861
  }, [pushMessage, stopLiveVoice]);
@@ -1197,6 +1346,26 @@ export const AstraApp = (): React.JSX.Element => {
1197
1346
 
1198
1347
  if (event.type === "credits_exhausted") {
1199
1348
  setCreditsRemaining(0);
1349
+ if (voiceEnabled) {
1350
+ await stopLiveVoice();
1351
+ setVoiceEnabled(false);
1352
+ pushMessage("system", "Voice input paused: credits exhausted. Recharge, then run /voice on.");
1353
+ pushMessage("system", "");
1354
+ }
1355
+ }
1356
+
1357
+ if (event.type === "continuation_check") {
1358
+ const recommendation =
1359
+ typeof event.recommendation === "string" && event.recommendation
1360
+ ? event.recommendation
1361
+ : "Please narrow the scope and continue with a specific target.";
1362
+ const streak = Number(event.consecutive_read_only_iterations ?? 0);
1363
+ const threshold = Number(event.threshold ?? 0);
1364
+ pushMessage(
1365
+ "system",
1366
+ `Exploration paused (${streak}/${threshold} read-only turns). ${recommendation}`
1367
+ );
1368
+ return null;
1200
1369
  }
1201
1370
 
1202
1371
  if (!isSuperAdmin && NOISY_EVENT_TYPES.has(event.type)) {
@@ -1204,14 +1373,6 @@ export const AstraApp = (): React.JSX.Element => {
1204
1373
  }
1205
1374
 
1206
1375
  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
1376
  return null;
1216
1377
  }
1217
1378
 
@@ -1225,11 +1386,23 @@ export const AstraApp = (): React.JSX.Element => {
1225
1386
  } else if (event.type === "tool_result") {
1226
1387
  const mark = event.success ? "completed" : "failed";
1227
1388
  const toolName = typeof event.tool_name === "string" ? event.tool_name : "tool";
1389
+ const payload = (event.data ?? {}) as Record<string, unknown>;
1390
+ const resultType = typeof event.result_type === "string" ? event.result_type : "";
1391
+ const alreadyRepresented =
1392
+ resultType === "file_create" ||
1393
+ resultType === "file_edit" ||
1394
+ resultType === "file_delete" ||
1395
+ toolName === "start_preview" ||
1396
+ toolName === "capture_screenshot";
1397
+ if (alreadyRepresented) {
1398
+ return null;
1399
+ }
1400
+ const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
1228
1401
  pushToolCard({
1229
1402
  kind: event.success ? "success" : "error",
1230
1403
  toolName,
1231
- locality: ((event.data ?? {}) as Record<string, unknown>).local === true ? "LOCAL" : "REMOTE",
1232
- summary: `${toolName} ${mark}`
1404
+ locality: payload.local === true ? "LOCAL" : "REMOTE",
1405
+ summary: event.success ? summary : `${toolName} ${mark}`
1233
1406
  });
1234
1407
  }
1235
1408
  }
@@ -1241,7 +1414,7 @@ export const AstraApp = (): React.JSX.Element => {
1241
1414
  }
1242
1415
  return null;
1243
1416
  },
1244
- [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, writeLocalFile]
1417
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
1245
1418
  );
1246
1419
 
1247
1420
  const sendPrompt = useCallback(
@@ -1254,15 +1427,27 @@ export const AstraApp = (): React.JSX.Element => {
1254
1427
  if (text === "/help") {
1255
1428
  pushMessage(
1256
1429
  "system",
1257
- "/new /history /voice /voice on|off|status /settings /settings model <id> /logout /exit"
1430
+ "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
1258
1431
  );
1259
1432
  pushMessage("system", "");
1260
1433
  return;
1261
1434
  }
1435
+ if (text === "/tools compact") {
1436
+ setToolFeedMode("compact");
1437
+ pushMessage("system", "Tool feed set to compact.");
1438
+ pushMessage("system", "");
1439
+ return;
1440
+ }
1441
+ if (text === "/tools expanded") {
1442
+ setToolFeedMode("expanded");
1443
+ pushMessage("system", "Tool feed set to expanded.");
1444
+ pushMessage("system", "");
1445
+ return;
1446
+ }
1262
1447
  if (text === "/settings") {
1263
1448
  pushMessage(
1264
1449
  "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()}`
1450
+ `Settings: mode=${runtimeMode} scope=${workspaceRoot} model=${activeModel} provider=${getProviderForModel(activeModel)} voice=${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
1451
  );
1267
1452
  pushMessage("system", "");
1268
1453
  return;
@@ -1300,34 +1485,34 @@ export const AstraApp = (): React.JSX.Element => {
1300
1485
  await openHistory();
1301
1486
  return;
1302
1487
  }
1303
- if (text === "/voice status") {
1488
+ if (text === "/dictate status") {
1304
1489
  pushMessage(
1305
1490
  "system",
1306
- `Voice input is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}${voiceWaitingForSilence ? " (waiting for silence to auto-send)" : ""}.`
1491
+ `Voice input is ${voiceEnabled ? "on" : "off"}${voicePreparing ? " (preparing mic)" : ""}${voiceListening ? " (listening)" : ""}${voiceWaitingForSilence ? " (waiting for silence to auto-send)" : ""}.`
1307
1492
  );
1308
1493
  pushMessage("system", "");
1309
1494
  return;
1310
1495
  }
1311
- if (text === "/voice on") {
1496
+ if (text === "/dictate on") {
1312
1497
  setVoiceEnabled(true);
1313
1498
  startLiveVoice(true);
1314
1499
  pushMessage("system", `Voice input enabled. Auto-send after ${Math.round(VOICE_SILENCE_MS / 1000)}s silence.`);
1315
1500
  pushMessage("system", "");
1316
1501
  return;
1317
1502
  }
1318
- if (text === "/voice off") {
1503
+ if (text === "/dictate off") {
1319
1504
  await stopLiveVoice();
1320
1505
  setVoiceEnabled(false);
1321
1506
  pushMessage("system", "Voice input disabled.");
1322
1507
  pushMessage("system", "");
1323
1508
  return;
1324
1509
  }
1325
- if (text === "/voice input") {
1510
+ if (text === "/dictate input") {
1326
1511
  const transcribed = await transcribeOnce();
1327
1512
  if (!transcribed) {
1328
1513
  pushMessage(
1329
1514
  "error",
1330
- "No speech transcribed. Ensure you're signed in and that mic capture works (optional ASTRA_STT_CAPTURE_COMMAND)."
1515
+ "No speech transcribed. Ensure you're signed in and your microphone capture works (optional ASTRA_STT_CAPTURE_COMMAND)."
1331
1516
  );
1332
1517
  return;
1333
1518
  }
@@ -1399,6 +1584,9 @@ export const AstraApp = (): React.JSX.Element => {
1399
1584
  localActionConfirmed = true;
1400
1585
  }
1401
1586
  }
1587
+ if (event.type === "done") {
1588
+ break;
1589
+ }
1402
1590
  const piece = await handleEvent(event, activeSessionId);
1403
1591
  if (piece) {
1404
1592
  assistant += piece;
@@ -1438,9 +1626,11 @@ export const AstraApp = (): React.JSX.Element => {
1438
1626
  startLiveVoice,
1439
1627
  stopLiveVoice,
1440
1628
  thinking,
1629
+ toolFeedMode,
1441
1630
  user,
1442
1631
  voiceEnabled,
1443
1632
  voiceListening,
1633
+ voicePreparing,
1444
1634
  voiceWaitingForSilence,
1445
1635
  workspaceRoot
1446
1636
  ]
@@ -1455,8 +1645,35 @@ export const AstraApp = (): React.JSX.Element => {
1455
1645
  if (!queued) {
1456
1646
  return;
1457
1647
  }
1648
+ const normalizedQueued = queued.toLowerCase();
1649
+ const last = lastVoicePromptRef.current;
1650
+ const isLikelyNoise = isLikelyVoiceNoise(normalizedQueued);
1651
+ const isFastDuplicate =
1652
+ last !== null &&
1653
+ last.text === normalizedQueued &&
1654
+ Date.now() - last.at <= VOICE_DUPLICATE_WINDOW_MS;
1655
+ // #region agent log
1656
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H3',location:'App.tsx:voiceQueuedPromptEffect',message:'queued prompt evaluated',data:{queued,normalizedQueued,isLikelyNoise,isFastDuplicate,thinking},timestamp:Date.now()})}).catch(()=>{});
1657
+ // #endregion
1658
+ if (isLikelyNoise || isFastDuplicate) {
1659
+ const now = Date.now();
1660
+ const lastIgnored = lastIgnoredVoiceRef.current;
1661
+ const shouldLogIgnored =
1662
+ !lastIgnored ||
1663
+ lastIgnored.text !== normalizedQueued ||
1664
+ now - lastIgnored.at > VOICE_DUPLICATE_WINDOW_MS;
1665
+ if (shouldLogIgnored) {
1666
+ pushMessage("system", `Ignored likely-noise dictation input: "${queued}"`);
1667
+ lastIgnoredVoiceRef.current = {text: normalizedQueued, at: now};
1668
+ }
1669
+ return;
1670
+ }
1671
+ lastVoicePromptRef.current = {text: normalizedQueued, at: Date.now()};
1458
1672
  pushMessage("system", `Voice input: ${queued}`);
1459
1673
  setPrompt("");
1674
+ // #region agent log
1675
+ fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H3',location:'App.tsx:voiceQueuedPromptEffect',message:'queued prompt forwarded to sendPrompt',data:{queuedLen:queued.length},timestamp:Date.now()})}).catch(()=>{});
1676
+ // #endregion
1460
1677
  void sendPrompt(queued);
1461
1678
  }, [pushMessage, sendPrompt, thinking, user, voiceQueuedPrompt]);
1462
1679
 
@@ -1500,8 +1717,8 @@ export const AstraApp = (): React.JSX.Element => {
1500
1717
  if (booting) {
1501
1718
  return (
1502
1719
  <Box flexDirection="column">
1503
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1504
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1720
+ <Text color="#7aa2ff">{brand}</Text>
1721
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1505
1722
  <Text color="#8aa2c9">
1506
1723
  <Spinner type="dots12" /> Booting Astra terminal shell...
1507
1724
  </Text>
@@ -1512,8 +1729,8 @@ export const AstraApp = (): React.JSX.Element => {
1512
1729
  if (bootError) {
1513
1730
  return (
1514
1731
  <Box flexDirection="column">
1515
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1516
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1732
+ <Text color="#7aa2ff">{brand}</Text>
1733
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1517
1734
  <Text color="red">{bootError}</Text>
1518
1735
  </Box>
1519
1736
  );
@@ -1522,8 +1739,8 @@ export const AstraApp = (): React.JSX.Element => {
1522
1739
  if (!user) {
1523
1740
  return (
1524
1741
  <Box flexDirection="column">
1525
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1526
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1742
+ <Text color="#7aa2ff">{brand}</Text>
1743
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1527
1744
  <Text color="#b8c8ff">
1528
1745
  Astra terminal AI pair programmer ({loginMode === "login" ? "Sign in" : "Create account"})
1529
1746
  </Text>
@@ -1567,16 +1784,16 @@ export const AstraApp = (): React.JSX.Element => {
1567
1784
  const selected = filteredHistory[historyIndex];
1568
1785
  return (
1569
1786
  <Box flexDirection="column">
1570
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1571
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1572
- <Text color="#2a3a50">{DIVIDER}</Text>
1787
+ <Text color="#7aa2ff">{brand}</Text>
1788
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1789
+ <Text color="#2a3a50">{divider}</Text>
1573
1790
  {historyMode === "picker" ? (
1574
1791
  <Box flexDirection="column">
1575
1792
  <Box flexDirection="row" justifyContent="space-between">
1576
1793
  <Text color="#dce9ff">History Picker</Text>
1577
1794
  <Text color="#5a7a9a">Esc close · Enter select</Text>
1578
1795
  </Box>
1579
- <Text color="#2a3a50">{DIVIDER}</Text>
1796
+ <Text color="#2a3a50">{divider}</Text>
1580
1797
  <Box marginTop={1} flexDirection="column">
1581
1798
  <Box flexDirection="row">
1582
1799
  <Text color={historyPickerIndex === 0 ? "#dce9ff" : "#7a9bba"}>
@@ -1589,7 +1806,7 @@ export const AstraApp = (): React.JSX.Element => {
1589
1806
  </Text>
1590
1807
  </Box>
1591
1808
  </Box>
1592
- <Text color="#2a3a50">{DIVIDER}</Text>
1809
+ <Text color="#2a3a50">{divider}</Text>
1593
1810
  <Text color="#5a7a9a">Credit usage history opens: {HISTORY_SETTINGS_URL}</Text>
1594
1811
  </Box>
1595
1812
  ) : (
@@ -1602,7 +1819,7 @@ export const AstraApp = (): React.JSX.Element => {
1602
1819
  <Text color="#4a6070">search </Text>
1603
1820
  <TextInput value={historyQuery} onChange={setHistoryQuery} placeholder="Filter chats..." />
1604
1821
  </Box>
1605
- <Text color="#2a3a50">{DIVIDER}</Text>
1822
+ <Text color="#2a3a50">{divider}</Text>
1606
1823
  {historyLoading ? (
1607
1824
  <Text color="#8aa2c9">
1608
1825
  <Spinner type="dots12" /> Loading chat history...
@@ -1628,7 +1845,7 @@ export const AstraApp = (): React.JSX.Element => {
1628
1845
  })}
1629
1846
  </Box>
1630
1847
  )}
1631
- <Text color="#2a3a50">{DIVIDER}</Text>
1848
+ <Text color="#2a3a50">{divider}</Text>
1632
1849
  <Box flexDirection="row" justifyContent="space-between">
1633
1850
  <Text color="#5a7a9a">
1634
1851
  Page {historyPage + 1} / {historyPageCount}
@@ -1643,57 +1860,53 @@ export const AstraApp = (): React.JSX.Element => {
1643
1860
 
1644
1861
  return (
1645
1862
  <Box flexDirection="column">
1646
- <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1647
- <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1863
+ <Text color="#7aa2ff">{brand}</Text>
1864
+ <Text color="#8ea1bd">{welcomeLine}</Text>
1648
1865
  {/*<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>
1866
+ <Text color="#2a3a50">{divider}</Text>
1867
+ <Text color="#7a9bba">
1868
+ {`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
1869
+ creditsRemaining ?? "--"
1870
+ }${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}`}
1871
+ </Text>
1872
+ <Text color="#6c88a8">
1873
+ {`scope ${
1874
+ workspaceRoot.length > HEADER_PATH_MAX ? `…${workspaceRoot.slice(-(HEADER_PATH_MAX - 1))}` : workspaceRoot
1875
+ } · voice ${
1876
+ voiceEnabled
1877
+ ? voicePreparing
1878
+ ? "on/preparing"
1879
+ : voiceListening
1880
+ ? voiceWaitingForSilence
1881
+ ? "on/waiting"
1882
+ : "on/listening"
1883
+ : "on/standby"
1884
+ : "off"
1885
+ }`}
1886
+ </Text>
1887
+ <Text color="#2a3a50">{divider}</Text>
1888
+ <Text color="#3a5068">/help /new /history /voice /dictate on|off|status</Text>
1889
+ <Text color="#3a5068">/tools compact|expanded /settings /logout /exit</Text>
1890
+ <Text color="#2a3a50">{divider}</Text>
1686
1891
  <Box flexDirection="column" marginTop={1}>
1687
1892
  {messages.map((message, index) => {
1893
+ const prev = index > 0 ? messages[index - 1] : null;
1688
1894
  const style = styleForKind(message.kind);
1689
1895
  const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
1690
1896
  const isSpacing = message.text === "" && message.kind === "system";
1897
+ const needsGroupGap =
1898
+ Boolean(prev) &&
1899
+ prev?.kind !== "system" &&
1900
+ message.kind !== "system" &&
1901
+ prev?.kind !== message.kind &&
1902
+ (message.kind === "assistant" || message.kind === "tool");
1691
1903
  if (isSpacing) {
1692
1904
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1693
1905
  }
1694
1906
 
1695
1907
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1696
1908
  const card = message.card;
1909
+ const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1697
1910
  const icon =
1698
1911
  card.kind === "error"
1699
1912
  ? "✕"
@@ -1723,38 +1936,50 @@ export const AstraApp = (): React.JSX.Element => {
1723
1936
  ? "#9ad5ff"
1724
1937
  : "#9bc5ff";
1725
1938
  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}
1939
+ <React.Fragment key={`${index}-${message.kind}`}>
1940
+ {needsGroupGap ? <Text> </Text> : null}
1941
+ <Box flexDirection="row">
1942
+ <Text color={style.labelColor}>{paddedLabel}</Text>
1943
+ <Box flexDirection="column">
1944
+ <Text color={accent}>
1945
+ {icon} {card.summary}
1946
+ {card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
1947
+ <Text color="#5a7a9a"> · {localityLabel}</Text>
1736
1948
  </Text>
1737
- ))}
1949
+ {toolFeedMode === "expanded" ? (
1950
+ <>
1951
+ {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
1952
+ {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
1953
+ <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
1954
+ {line}
1955
+ </Text>
1956
+ ))}
1957
+ </>
1958
+ ) : null}
1959
+ </Box>
1738
1960
  </Box>
1739
- </Box>
1961
+ </React.Fragment>
1740
1962
  );
1741
1963
  }
1742
1964
 
1743
1965
  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}
1966
+ <React.Fragment key={`${index}-${message.kind}`}>
1967
+ {needsGroupGap ? <Text> </Text> : null}
1968
+ <Box flexDirection="row">
1969
+ <Text color={style.labelColor} bold={style.bold}>
1970
+ {paddedLabel}
1755
1971
  </Text>
1756
- )}
1757
- </Box>
1972
+ {message.kind === "assistant" ? (
1973
+ <Box flexDirection="column">
1974
+ {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
1975
+ </Box>
1976
+ ) : (
1977
+ <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
1978
+ {message.text}
1979
+ </Text>
1980
+ )}
1981
+ </Box>
1982
+ </React.Fragment>
1758
1983
  );
1759
1984
  })}
1760
1985
  {streamingText ? (
@@ -1764,7 +1989,7 @@ export const AstraApp = (): React.JSX.Element => {
1764
1989
  </Box>
1765
1990
  ) : null}
1766
1991
  </Box>
1767
- <Text color="#2a3a50">{DIVIDER}</Text>
1992
+ <Text color="#2a3a50">{divider}</Text>
1768
1993
  {thinking ? (
1769
1994
  <Box flexDirection="row" marginTop={1}>
1770
1995
  <Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
@@ -1777,12 +2002,14 @@ export const AstraApp = (): React.JSX.Element => {
1777
2002
  {voiceEnabled && !thinking ? (
1778
2003
  <Box flexDirection="row" marginTop={1}>
1779
2004
  <Text color="#9ad5ff">{"🎤 voice".padEnd(LABEL_WIDTH, " ")}</Text>
1780
- {voiceListening && !voiceWaitingForSilence ? (
1781
- <Text color="#a6d9ff">🎙 listening... goblin ears activated 👂</Text>
2005
+ {voicePreparing ? (
2006
+ <Text color="#f4d58a">🟡 preparing microphone...</Text>
2007
+ ) : voiceListening && !voiceWaitingForSilence ? (
2008
+ <Text color="#9de3b4">🟢 listening now - speak clearly</Text>
1782
2009
  ) : voiceWaitingForSilence ? (
1783
- <Text color="#b7c4d8">⏸ waiting for silence... dramatic pause loading...</Text>
2010
+ <Text color="#b7c4d8">⏳ speech detected - waiting for silence to send</Text>
1784
2011
  ) : (
1785
- <Text color="#6f8199">voice armed... say something when ready</Text>
2012
+ <Text color="#6f8199">⚪ voice armed - preparing next listen window</Text>
1786
2013
  )}
1787
2014
  </Box>
1788
2015
  ) : null}