@astra-code/astra-ai 0.1.0 → 0.1.2

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
@@ -17,17 +17,27 @@ import {
17
17
  import {runTerminalCommand} from "../lib/terminalBridge.js";
18
18
  import {isWorkspaceTrusted, trustWorkspace} from "../lib/trustStore.js";
19
19
  import {scanWorkspace} from "../lib/workspaceScanner.js";
20
- import {speakText, startLiveTranscription, transcribeOnce, type LiveTranscriptionController} from "../lib/voice.js";
20
+ import {startLiveTranscription, transcribeOnce, type LiveTranscriptionController} from "../lib/voice.js";
21
21
  import type {AgentEvent, AuthSession, ChatMessage} from "../types/events.js";
22
22
  import type {WorkspaceFile} from "../lib/workspaceScanner.js";
23
23
 
24
24
  type UiMessage = {
25
25
  kind: "system" | "user" | "assistant" | "tool" | "error";
26
26
  text: string;
27
+ card?: ToolCard;
27
28
  };
28
29
 
29
30
  type HistoryMode = "picker" | "sessions";
30
31
 
32
+ type ToolCard = {
33
+ kind: "start" | "success" | "error" | "fileCreate" | "fileEdit" | "fileDelete" | "terminal" | "preview";
34
+ toolName: string;
35
+ locality: "LOCAL" | "REMOTE";
36
+ summary: string;
37
+ path?: string;
38
+ snippetLines?: string[];
39
+ };
40
+
31
41
  // const ASTRA_ASCII = `
32
42
  // █████╗ ███████╗████████╗██████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗
33
43
  // ██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
@@ -63,10 +73,22 @@ const centerLine = (text: string, width = WELCOME_WIDTH): string => {
63
73
  const FOUNDER_WELCOME = centerLine("Welcome to Astra from Astra CEO & Founder, Sean Donovan");
64
74
 
65
75
  const HISTORY_SETTINGS_URL = "https://astra-web-builder.vercel.app/settings";
76
+ const VOICE_SILENCE_MS = Number(process.env.ASTRA_VOICE_SILENCE_MS ?? "3000");
77
+ const TOOL_SNIPPET_LINES = 6;
78
+ const NOISY_EVENT_TYPES = new Set([
79
+ "timing",
80
+ "session_info",
81
+ "axon_status",
82
+ "orchestration_plan",
83
+ "task_plan",
84
+ "task_update",
85
+ "preset_status"
86
+ ]);
66
87
 
67
88
  const eventToToolLine = (event: AgentEvent): string | null => {
68
89
  if (event.type === "tool_start") {
69
- const name = event.tool?.name ?? "tool";
90
+ const tool = event.tool as {name?: string} | undefined;
91
+ const name = tool?.name ?? "tool";
70
92
  return `↳ ${name} executing...`;
71
93
  }
72
94
  if (event.type === "tool_result") {
@@ -74,9 +96,12 @@ const eventToToolLine = (event: AgentEvent): string | null => {
74
96
  const mark = success ? "✓" : "✗";
75
97
  const toolName = event.tool_name ?? "tool";
76
98
  const payload = (event.data ?? {}) as Record<string, unknown>;
99
+ const screenshotPath = typeof payload.path === "string" ? payload.path : "";
77
100
  const output = String(
78
101
  (payload.output as string | undefined) ??
79
102
  (payload.content as string | undefined) ??
103
+ (payload.summary as string | undefined) ??
104
+ (toolName === "capture_screenshot" && screenshotPath ? `Saved screenshot: ${screenshotPath}` : undefined) ??
80
105
  event.error ??
81
106
  ""
82
107
  );
@@ -94,6 +119,28 @@ const eventToToolLine = (event: AgentEvent): string | null => {
94
119
  return null;
95
120
  };
96
121
 
122
+ const extractSnippetLines = (content: string, maxLines = TOOL_SNIPPET_LINES): string[] => {
123
+ if (!content) {
124
+ return [];
125
+ }
126
+ const lines = content.split("\n").map((line) => line.trimEnd());
127
+ if (lines.length <= maxLines) {
128
+ return lines;
129
+ }
130
+ return lines.slice(0, maxLines);
131
+ };
132
+
133
+ const extractDiffSnippet = (diff: unknown, maxLines = TOOL_SNIPPET_LINES): string[] => {
134
+ if (!Array.isArray(diff)) {
135
+ return [];
136
+ }
137
+ const interesting = diff
138
+ .map((line) => String(line))
139
+ .filter((line) => line.startsWith("+") || line.startsWith("-"))
140
+ .map((line) => line.slice(0, 220));
141
+ return interesting.slice(0, maxLines);
142
+ };
143
+
97
144
  const looksLikeLocalFilesystemClaim = (text: string): boolean => {
98
145
  const lower = text.toLowerCase();
99
146
  const changeWord =
@@ -518,6 +565,8 @@ export const AstraApp = (): React.JSX.Element => {
518
565
  const [streamingText, setStreamingText] = useState("");
519
566
  const [voiceEnabled, setVoiceEnabled] = useState(false);
520
567
  const [voiceListening, setVoiceListening] = useState(false);
568
+ const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
569
+ const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
521
570
  const [historyOpen, setHistoryOpen] = useState(false);
522
571
  const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
523
572
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -526,11 +575,18 @@ export const AstraApp = (): React.JSX.Element => {
526
575
  const [historyRows, setHistoryRows] = useState<SessionSummary[]>([]);
527
576
  const [historyIndex, setHistoryIndex] = useState(0);
528
577
  const liveVoiceRef = useRef<LiveTranscriptionController | null>(null);
578
+ const voiceSilenceTimerRef = useRef<NodeJS.Timeout | null>(null);
579
+ const fileEditBuffersRef = useRef<Map<string, {path: string; chunks: string[]; toolName: string | undefined}>>(new Map());
580
+ const isSuperAdmin = user?.role === "super_admin";
529
581
 
530
582
  const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
531
583
  setMessages((prev) => [...prev, {kind, text}].slice(-300));
532
584
  }, []);
533
585
 
586
+ const pushToolCard = useCallback((card: ToolCard) => {
587
+ setMessages((prev) => [...prev, {kind: "tool", text: card.summary, card} satisfies UiMessage].slice(-300));
588
+ }, []);
589
+
534
590
  const filteredHistory = useMemo(() => {
535
591
  const q = historyQuery.trim().toLowerCase();
536
592
  if (!q) {
@@ -597,37 +653,70 @@ export const AstraApp = (): React.JSX.Element => {
597
653
  }, [backend, pushMessage, user]);
598
654
 
599
655
  const stopLiveVoice = useCallback(async (): Promise<void> => {
656
+ if (voiceSilenceTimerRef.current) {
657
+ clearTimeout(voiceSilenceTimerRef.current);
658
+ voiceSilenceTimerRef.current = null;
659
+ }
600
660
  const controller = liveVoiceRef.current;
601
661
  if (!controller) {
662
+ setVoiceWaitingForSilence(false);
602
663
  return;
603
664
  }
604
665
  liveVoiceRef.current = null;
605
666
  await controller.stop();
606
667
  setVoiceListening(false);
668
+ setVoiceWaitingForSilence(false);
607
669
  }, []);
608
670
 
609
- const startLiveVoice = useCallback((): void => {
671
+ const startLiveVoice = useCallback((announce = true): void => {
610
672
  if (liveVoiceRef.current) {
611
673
  return;
612
674
  }
613
675
  setVoiceEnabled(true);
614
676
  setVoiceListening(true);
615
- pushMessage("system", "Live transcription started. Speak now…");
677
+ setVoiceWaitingForSilence(false);
678
+ if (announce) {
679
+ pushMessage("system", "Voice input started. Speak now…");
680
+ }
616
681
  liveVoiceRef.current = startLiveTranscription({
617
682
  onPartial: (text) => {
618
683
  setPrompt(text);
684
+ if (voiceSilenceTimerRef.current) {
685
+ clearTimeout(voiceSilenceTimerRef.current);
686
+ }
687
+ const candidate = text.trim();
688
+ if (!candidate) {
689
+ return;
690
+ }
691
+ setVoiceWaitingForSilence(true);
692
+ voiceSilenceTimerRef.current = setTimeout(() => {
693
+ setVoiceQueuedPrompt(candidate);
694
+ void stopLiveVoice();
695
+ }, VOICE_SILENCE_MS);
619
696
  },
620
697
  onFinal: (text) => {
698
+ if (voiceSilenceTimerRef.current) {
699
+ clearTimeout(voiceSilenceTimerRef.current);
700
+ voiceSilenceTimerRef.current = null;
701
+ }
621
702
  setPrompt(text);
703
+ liveVoiceRef.current = null;
704
+ setVoiceListening(false);
705
+ setVoiceWaitingForSilence(false);
622
706
  },
623
707
  onError: (error) => {
708
+ setVoiceWaitingForSilence(false);
624
709
  pushMessage("error", `Voice transcription error: ${error.message}`);
625
710
  }
626
711
  });
627
- }, [pushMessage]);
712
+ }, [pushMessage, stopLiveVoice]);
628
713
 
629
714
  useEffect(() => {
630
715
  return () => {
716
+ if (voiceSilenceTimerRef.current) {
717
+ clearTimeout(voiceSilenceTimerRef.current);
718
+ voiceSilenceTimerRef.current = null;
719
+ }
631
720
  const controller = liveVoiceRef.current;
632
721
  if (controller) {
633
722
  void controller.stop();
@@ -635,6 +724,13 @@ export const AstraApp = (): React.JSX.Element => {
635
724
  };
636
725
  }, []);
637
726
 
727
+ useEffect(() => {
728
+ if (!voiceEnabled || !user || thinking || voiceListening || liveVoiceRef.current) {
729
+ return;
730
+ }
731
+ startLiveVoice(false);
732
+ }, [startLiveVoice, thinking, user, voiceEnabled, voiceListening]);
733
+
638
734
  useEffect(() => {
639
735
  if (!trustedWorkspace) {
640
736
  return;
@@ -652,6 +748,7 @@ export const AstraApp = (): React.JSX.Element => {
652
748
 
653
749
  const cached = loadSession();
654
750
  if (!cached) {
751
+ backend.setAuthSession(null);
655
752
  if (!cancelled) {
656
753
  setBooting(false);
657
754
  }
@@ -661,8 +758,12 @@ export const AstraApp = (): React.JSX.Element => {
661
758
  const valid = await backend.validateSession(cached);
662
759
  if (!valid) {
663
760
  clearSession();
761
+ backend.setAuthSession(null);
664
762
  } else if (!cancelled) {
665
- setUser(cached);
763
+ const hydrated = await backend.getUserProfile(cached).catch(() => cached);
764
+ saveSession(hydrated);
765
+ backend.setAuthSession(hydrated);
766
+ setUser(hydrated);
666
767
  setMessages([
667
768
  {kind: "system", text: "Welcome back. Type a message or /help."},
668
769
  {kind: "system", text: ""}
@@ -796,7 +897,7 @@ export const AstraApp = (): React.JSX.Element => {
796
897
  try {
797
898
  await backend.deleteSession(selected.id);
798
899
  setHistoryRows((prev) => prev.filter((row) => row.id !== selected.id));
799
- pushMessage("tool", `Archived session: ${selected.title}`);
900
+ pushMessage("system", `Archived session: ${selected.title}`);
800
901
  } catch (error) {
801
902
  pushMessage("error", `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`);
802
903
  }
@@ -833,8 +934,10 @@ export const AstraApp = (): React.JSX.Element => {
833
934
  }
834
935
 
835
936
  const authSession = data as AuthSession;
836
- saveSession(authSession);
837
- setUser(authSession);
937
+ const hydrated = await backend.getUserProfile(authSession).catch(() => authSession);
938
+ saveSession(hydrated);
939
+ backend.setAuthSession(hydrated);
940
+ setUser(hydrated);
838
941
  setEmail("");
839
942
  setPassword("");
840
943
  setMessages([
@@ -850,8 +953,91 @@ export const AstraApp = (): React.JSX.Element => {
850
953
 
851
954
  const handleEvent = useCallback(
852
955
  async (event: AgentEvent, activeSessionId: string): Promise<string | null> => {
956
+ if (event.type === "file_edit_start") {
957
+ const data = (event.data ?? {}) as Record<string, unknown>;
958
+ const toolId =
959
+ typeof event.tool_id === "string"
960
+ ? event.tool_id
961
+ : typeof data.tool_id === "string"
962
+ ? data.tool_id
963
+ : "";
964
+ const toolName =
965
+ typeof event.tool_name === "string"
966
+ ? event.tool_name
967
+ : typeof data.tool_name === "string"
968
+ ? data.tool_name
969
+ : undefined;
970
+ if (toolId) {
971
+ const current = fileEditBuffersRef.current.get(toolId) ?? {path: "(unknown)", chunks: [] as string[], toolName: undefined};
972
+ fileEditBuffersRef.current.set(toolId, {...current, toolName});
973
+ }
974
+ return null;
975
+ }
976
+
977
+ if (event.type === "file_edit_path") {
978
+ const data = (event.data ?? {}) as Record<string, unknown>;
979
+ const toolId =
980
+ typeof event.tool_id === "string"
981
+ ? event.tool_id
982
+ : typeof data.tool_id === "string"
983
+ ? data.tool_id
984
+ : "";
985
+ const path =
986
+ typeof event.path === "string"
987
+ ? event.path
988
+ : typeof data.path === "string"
989
+ ? data.path
990
+ : "(unknown)";
991
+ const toolName =
992
+ typeof event.tool_name === "string"
993
+ ? event.tool_name
994
+ : typeof data.tool_name === "string"
995
+ ? data.tool_name
996
+ : undefined;
997
+ if (toolId) {
998
+ const current = fileEditBuffersRef.current.get(toolId);
999
+ fileEditBuffersRef.current.set(toolId, {path, chunks: [] as string[], toolName: toolName ?? current?.toolName});
1000
+ }
1001
+ if (!isSuperAdmin) {
1002
+ const resolvedToolName = toolName ?? fileEditBuffersRef.current.get(toolId)?.toolName;
1003
+ const usingBulkWriter = resolvedToolName === "bulk_file_writer";
1004
+ pushToolCard({
1005
+ kind: "fileEdit",
1006
+ toolName: resolvedToolName ?? "edit_file",
1007
+ locality: "LOCAL",
1008
+ summary: usingBulkWriter ? `Writing file ${path}` : `Editing ${path}`,
1009
+ path
1010
+ });
1011
+ }
1012
+ return null;
1013
+ }
1014
+
1015
+ if (event.type === "file_edit_delta") {
1016
+ const data = (event.data ?? {}) as Record<string, unknown>;
1017
+ const toolId =
1018
+ typeof event.tool_id === "string"
1019
+ ? event.tool_id
1020
+ : typeof data.tool_id === "string"
1021
+ ? data.tool_id
1022
+ : "";
1023
+ const chunk = typeof event.content === "string" ? event.content : "";
1024
+ if (toolId && chunk) {
1025
+ const current = fileEditBuffersRef.current.get(toolId) ?? {path: "(unknown)", chunks: [] as string[], toolName: undefined};
1026
+ current.chunks.push(chunk);
1027
+ fileEditBuffersRef.current.set(toolId, current);
1028
+ }
1029
+ if (isSuperAdmin) {
1030
+ const raw = JSON.stringify(event);
1031
+ pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
1032
+ }
1033
+ return null;
1034
+ }
1035
+
853
1036
  const assistantPiece = extractAssistantText(event);
854
- if (assistantPiece && !["tool_result", "error", "credits_update", "credits_exhausted"].includes(event.type)) {
1037
+ if (
1038
+ assistantPiece &&
1039
+ !["tool_result", "error", "credits_update", "credits_exhausted", "file_edit_delta", "file_edit_path"].includes(event.type)
1040
+ ) {
855
1041
  return assistantPiece;
856
1042
  }
857
1043
 
@@ -872,22 +1058,45 @@ export const AstraApp = (): React.JSX.Element => {
872
1058
  });
873
1059
  }
874
1060
 
875
- pushMessage("tool", `[LOCAL] ▸ ran: ${command.slice(0, 60)}...`);
1061
+ if (isSuperAdmin) {
1062
+ pushMessage("tool", `[LOCAL] ▸ ran: ${command.slice(0, 60)}...`);
1063
+ } else {
1064
+ pushToolCard({
1065
+ kind: "terminal",
1066
+ toolName: "run_in_terminal",
1067
+ locality: "LOCAL",
1068
+ summary: `Running command: ${command.slice(0, 80)}`
1069
+ });
1070
+ }
876
1071
  return null;
877
1072
  }
878
1073
 
879
1074
  // Apply file operations to the local workspace immediately.
880
1075
  if (event.type === "tool_result" && event.success) {
881
1076
  const d = (event.data ?? {}) as Record<string, unknown>;
882
- const resultType = (event as Record<string, unknown>).result_type as string | undefined;
1077
+ const resultType = event.result_type;
1078
+ const toolResultId = typeof event.tool_call_id === "string" ? event.tool_call_id : "";
883
1079
  const relPath = typeof d.path === "string" ? d.path : null;
884
1080
  const lang = typeof d.language === "string" ? d.language : "plaintext";
1081
+ const locality: "LOCAL" | "REMOTE" = d.local === true ? "LOCAL" : "REMOTE";
885
1082
 
886
1083
  if (relPath) {
887
1084
  if (resultType === "file_create") {
888
1085
  const content = typeof d.content === "string" ? d.content : "";
889
1086
  writeLocalFile(relPath, content, lang);
890
- pushMessage("tool", `[LOCAL] wrote ${relPath}`);
1087
+ const snippetLines = extractSnippetLines(content);
1088
+ if (isSuperAdmin) {
1089
+ pushMessage("tool", `[LOCAL] ✓ wrote ${relPath}`);
1090
+ } else {
1091
+ pushToolCard({
1092
+ kind: "fileCreate",
1093
+ toolName: "create_file",
1094
+ locality,
1095
+ summary: `Created ${relPath}`,
1096
+ path: relPath,
1097
+ snippetLines
1098
+ });
1099
+ }
891
1100
  } else if (resultType === "file_edit") {
892
1101
  const content =
893
1102
  typeof d.full_new_content === "string"
@@ -897,13 +1106,79 @@ export const AstraApp = (): React.JSX.Element => {
897
1106
  : null;
898
1107
  if (content !== null) {
899
1108
  writeLocalFile(relPath, content, lang);
900
- pushMessage("tool", `[LOCAL] edited ${relPath}`);
1109
+ const snippetLines = extractDiffSnippet(d.diff) ?? extractSnippetLines(content);
1110
+ if (isSuperAdmin) {
1111
+ pushMessage("tool", `[LOCAL] ✓ edited ${relPath}`);
1112
+ } else {
1113
+ const toolId = toolResultId;
1114
+ const streamed = toolId ? fileEditBuffersRef.current.get(toolId) : undefined;
1115
+ const streamedSnippet = streamed ? extractSnippetLines(streamed.chunks.join("")) : [];
1116
+ pushToolCard({
1117
+ kind: "fileEdit",
1118
+ toolName: "edit_file",
1119
+ locality,
1120
+ summary: `Updated ${relPath}`,
1121
+ path: relPath,
1122
+ snippetLines: streamedSnippet.length > 0 ? streamedSnippet : snippetLines
1123
+ });
1124
+ if (toolId) {
1125
+ fileEditBuffersRef.current.delete(toolId);
1126
+ }
1127
+ }
901
1128
  }
902
1129
  } else if (resultType === "file_delete") {
903
1130
  deleteLocalFile(relPath);
904
- pushMessage("tool", `[LOCAL] ✓ deleted ${relPath}`);
1131
+ if (isSuperAdmin) {
1132
+ pushMessage("tool", `[LOCAL] ✓ deleted ${relPath}`);
1133
+ } else {
1134
+ pushToolCard({
1135
+ kind: "fileDelete",
1136
+ toolName: "delete_file",
1137
+ locality,
1138
+ summary: `Deleted ${relPath}`,
1139
+ path: relPath
1140
+ });
1141
+ }
905
1142
  }
906
1143
  }
1144
+
1145
+ if (!isSuperAdmin && event.tool_name === "start_preview") {
1146
+ const message =
1147
+ typeof d.message === "string"
1148
+ ? d.message
1149
+ : typeof d.preview_url === "string"
1150
+ ? `Preview: ${d.preview_url}`
1151
+ : "Local preview started.";
1152
+ pushToolCard({
1153
+ kind: "preview",
1154
+ toolName: "start_preview",
1155
+ locality,
1156
+ summary: message
1157
+ });
1158
+ return null;
1159
+ }
1160
+
1161
+ if (!isSuperAdmin && event.tool_name === "capture_screenshot") {
1162
+ const screenshotPath = typeof d.path === "string" ? d.path : "";
1163
+ const targetUrl = typeof d.url === "string" ? d.url : "";
1164
+ const message = screenshotPath
1165
+ ? `Screenshot saved: ${screenshotPath}`
1166
+ : targetUrl
1167
+ ? `Captured screenshot: ${targetUrl}`
1168
+ : "Screenshot captured.";
1169
+ pushToolCard({
1170
+ kind: "preview",
1171
+ toolName: "capture_screenshot",
1172
+ locality,
1173
+ summary: message,
1174
+ ...(screenshotPath ? {path: screenshotPath} : {})
1175
+ });
1176
+ return null;
1177
+ }
1178
+
1179
+ if (toolResultId) {
1180
+ fileEditBuffersRef.current.delete(toolResultId);
1181
+ }
907
1182
  }
908
1183
 
909
1184
  if (event.type === "credits_update") {
@@ -922,20 +1197,49 @@ export const AstraApp = (): React.JSX.Element => {
922
1197
  setCreditsRemaining(0);
923
1198
  }
924
1199
 
1200
+ if (!isSuperAdmin && NOISY_EVENT_TYPES.has(event.type)) {
1201
+ return null;
1202
+ }
1203
+
1204
+ 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
+ return null;
1214
+ }
1215
+
925
1216
  const toolLine = eventToToolLine(event);
926
1217
  if (toolLine) {
927
1218
  if (event.type === "error" || event.type === "credits_exhausted") {
928
1219
  pushMessage("error", toolLine);
929
1220
  } else {
930
- pushMessage("tool", toolLine);
1221
+ if (isSuperAdmin) {
1222
+ pushMessage("tool", toolLine);
1223
+ } else if (event.type === "tool_result") {
1224
+ const mark = event.success ? "completed" : "failed";
1225
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "tool";
1226
+ pushToolCard({
1227
+ kind: event.success ? "success" : "error",
1228
+ toolName,
1229
+ locality: ((event.data ?? {}) as Record<string, unknown>).local === true ? "LOCAL" : "REMOTE",
1230
+ summary: `${toolName} ${mark}`
1231
+ });
1232
+ }
931
1233
  }
932
1234
  } else if (event.type !== "thinking") {
933
- const raw = JSON.stringify(event);
934
- pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
1235
+ if (isSuperAdmin) {
1236
+ const raw = JSON.stringify(event);
1237
+ pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
1238
+ }
935
1239
  }
936
1240
  return null;
937
1241
  },
938
- [backend, deleteLocalFile, pushMessage, writeLocalFile]
1242
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, writeLocalFile]
939
1243
  );
940
1244
 
941
1245
  const sendPrompt = useCallback(
@@ -948,7 +1252,7 @@ export const AstraApp = (): React.JSX.Element => {
948
1252
  if (text === "/help") {
949
1253
  pushMessage(
950
1254
  "system",
951
- "/new /history /voice on|off|status|start|stop|input /settings /settings model <id> /logout /exit"
1255
+ "/new /history /voice /voice on|off|status /settings /settings model <id> /logout /exit"
952
1256
  );
953
1257
  pushMessage("system", "");
954
1258
  return;
@@ -956,7 +1260,20 @@ export const AstraApp = (): React.JSX.Element => {
956
1260
  if (text === "/settings") {
957
1261
  pushMessage(
958
1262
  "system",
959
- `Settings: mode=${runtimeMode} scope=${workspaceRoot} model=${activeModel} provider=${getProviderForModel(activeModel)} voice=${voiceEnabled ? "on" : "off"} listening=${voiceListening ? "yes" : "no"} client_id=${getDefaultClientId()} backend=${getBackendUrl()}`
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()}`
1264
+ );
1265
+ pushMessage("system", "");
1266
+ return;
1267
+ }
1268
+ if (text === "/voice") {
1269
+ if (!voiceEnabled) {
1270
+ setVoiceEnabled(true);
1271
+ startLiveVoice(true);
1272
+ return;
1273
+ }
1274
+ pushMessage(
1275
+ "system",
1276
+ `Voice input is on${voiceListening ? " (currently listening)" : ""}. Use /voice off to disable.`
960
1277
  );
961
1278
  pushMessage("system", "");
962
1279
  return;
@@ -984,39 +1301,22 @@ export const AstraApp = (): React.JSX.Element => {
984
1301
  if (text === "/voice status") {
985
1302
  pushMessage(
986
1303
  "system",
987
- `Voice mode is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}.`
1304
+ `Voice input is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}${voiceWaitingForSilence ? " (waiting for silence to auto-send)" : ""}.`
988
1305
  );
989
1306
  pushMessage("system", "");
990
1307
  return;
991
1308
  }
992
1309
  if (text === "/voice on") {
993
1310
  setVoiceEnabled(true);
994
- pushMessage("system", "Voice mode enabled (assistant responses will be spoken).");
1311
+ startLiveVoice(true);
1312
+ pushMessage("system", `Voice input enabled. Auto-send after ${Math.round(VOICE_SILENCE_MS / 1000)}s silence.`);
995
1313
  pushMessage("system", "");
996
1314
  return;
997
1315
  }
998
1316
  if (text === "/voice off") {
999
1317
  await stopLiveVoice();
1000
1318
  setVoiceEnabled(false);
1001
- pushMessage("system", "Voice mode disabled.");
1002
- pushMessage("system", "");
1003
- return;
1004
- }
1005
- if (text === "/voice start") {
1006
- if (voiceListening) {
1007
- pushMessage("system", "Live transcription is already running.");
1008
- return;
1009
- }
1010
- startLiveVoice();
1011
- return;
1012
- }
1013
- if (text === "/voice stop") {
1014
- if (!voiceListening) {
1015
- pushMessage("system", "Live transcription is not running.");
1016
- return;
1017
- }
1018
- await stopLiveVoice();
1019
- pushMessage("system", "Live transcription stopped. Press Enter to send transcript.");
1319
+ pushMessage("system", "Voice input disabled.");
1020
1320
  pushMessage("system", "");
1021
1321
  return;
1022
1322
  }
@@ -1025,13 +1325,11 @@ export const AstraApp = (): React.JSX.Element => {
1025
1325
  if (!transcribed) {
1026
1326
  pushMessage(
1027
1327
  "error",
1028
- "No speech transcribed. Set ASTRA_STT_COMMAND to a command that prints transcript text to stdout."
1328
+ "No speech transcribed. Ensure you're signed in and that mic capture works (optional ASTRA_STT_CAPTURE_COMMAND)."
1029
1329
  );
1030
1330
  return;
1031
1331
  }
1032
- pushMessage("tool", `[LOCAL] 🎙 ${transcribed}`);
1033
- setPrompt(transcribed);
1034
- pushMessage("system", "Transcribed input ready. Press Enter to send.");
1332
+ setVoiceQueuedPrompt(transcribed.trim());
1035
1333
  return;
1036
1334
  }
1037
1335
  if (text === "/new") {
@@ -1045,6 +1343,7 @@ export const AstraApp = (): React.JSX.Element => {
1045
1343
  if (text === "/logout") {
1046
1344
  await stopLiveVoice();
1047
1345
  clearSession();
1346
+ backend.setAuthSession(null);
1048
1347
  setUser(null);
1049
1348
  setMessages([]);
1050
1349
  setChatMessages([]);
@@ -1113,9 +1412,6 @@ export const AstraApp = (): React.JSX.Element => {
1113
1412
  ? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
1114
1413
  : cleanedAssistant;
1115
1414
  pushMessage("assistant", guardedAssistant);
1116
- if (voiceEnabled) {
1117
- speakText(guardedAssistant);
1118
- }
1119
1415
  setChatMessages((prev) => [...prev, {role: "assistant", content: cleanedAssistant}]);
1120
1416
  } else {
1121
1417
  setChatMessages((prev) => [...prev, {role: "assistant", content: assistant}]);
@@ -1143,10 +1439,25 @@ export const AstraApp = (): React.JSX.Element => {
1143
1439
  user,
1144
1440
  voiceEnabled,
1145
1441
  voiceListening,
1442
+ voiceWaitingForSilence,
1146
1443
  workspaceRoot
1147
1444
  ]
1148
1445
  );
1149
1446
 
1447
+ useEffect(() => {
1448
+ if (!voiceQueuedPrompt || !user || thinking) {
1449
+ return;
1450
+ }
1451
+ const queued = voiceQueuedPrompt.trim();
1452
+ setVoiceQueuedPrompt(null);
1453
+ if (!queued) {
1454
+ return;
1455
+ }
1456
+ pushMessage("system", `Voice input: ${queued}`);
1457
+ setPrompt("");
1458
+ void sendPrompt(queued);
1459
+ }, [pushMessage, sendPrompt, thinking, user, voiceQueuedPrompt]);
1460
+
1150
1461
  if (!trustedWorkspace) {
1151
1462
  return (
1152
1463
  <Box flexDirection="column">
@@ -1363,12 +1674,12 @@ export const AstraApp = (): React.JSX.Element => {
1363
1674
  <Box flexDirection="row">
1364
1675
  <Text color="#4a6070">voice </Text>
1365
1676
  <Text color={voiceEnabled ? "#9ad5ff" : "#5a7a9a"}>
1366
- {voiceEnabled ? (voiceListening ? "on/listening" : "on") : "off"}
1677
+ {voiceEnabled ? (voiceListening ? (voiceWaitingForSilence ? "on/waiting" : "on/listening") : "on") : "off"}
1367
1678
  </Text>
1368
1679
  </Box>
1369
1680
  </Box>
1370
1681
  <Text color="#2a3a50">{DIVIDER}</Text>
1371
- <Text color="#3a5068">/help /new /history /voice on|off|status|start|stop|input /settings /logout /exit</Text>
1682
+ <Text color="#3a5068">/help /new /history /voice /voice on|off|status /settings /logout /exit</Text>
1372
1683
  <Text color="#2a3a50">{DIVIDER}</Text>
1373
1684
  <Box flexDirection="column" marginTop={1}>
1374
1685
  {messages.map((message, index) => {
@@ -1378,6 +1689,55 @@ export const AstraApp = (): React.JSX.Element => {
1378
1689
  if (isSpacing) {
1379
1690
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1380
1691
  }
1692
+
1693
+ if (message.kind === "tool" && message.card && !isSuperAdmin) {
1694
+ const card = message.card;
1695
+ const icon =
1696
+ card.kind === "error"
1697
+ ? "✕"
1698
+ : card.kind === "fileDelete"
1699
+ ? "🗑"
1700
+ : card.kind === "fileCreate"
1701
+ ? "+"
1702
+ : card.kind === "fileEdit"
1703
+ ? "✎"
1704
+ : card.kind === "preview"
1705
+ ? "◉"
1706
+ : card.kind === "terminal"
1707
+ ? "⌘"
1708
+ : card.kind === "start"
1709
+ ? "…"
1710
+ : "✓";
1711
+ const accent =
1712
+ card.kind === "error"
1713
+ ? "#ff8d8d"
1714
+ : card.kind === "fileDelete"
1715
+ ? "#8a96a6"
1716
+ : card.kind === "fileEdit"
1717
+ ? "#b7d4ff"
1718
+ : card.kind === "fileCreate"
1719
+ ? "#a5e9c5"
1720
+ : card.kind === "preview"
1721
+ ? "#9ad5ff"
1722
+ : "#9bc5ff";
1723
+ 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}
1734
+ </Text>
1735
+ ))}
1736
+ </Box>
1737
+ </Box>
1738
+ );
1739
+ }
1740
+
1381
1741
  return (
1382
1742
  <Box key={`${index}-${message.kind}`} flexDirection="row">
1383
1743
  <Text color={style.labelColor} bold={style.bold}>
@@ -1412,16 +1772,32 @@ export const AstraApp = (): React.JSX.Element => {
1412
1772
  </Text>
1413
1773
  </Box>
1414
1774
  ) : null}
1775
+ {voiceEnabled && !thinking ? (
1776
+ <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>
1780
+ ) : voiceWaitingForSilence ? (
1781
+ <Text color="#b7c4d8">⏸ waiting for silence... dramatic pause loading...</Text>
1782
+ ) : (
1783
+ <Text color="#6f8199">voice armed... say something when ready</Text>
1784
+ )}
1785
+ </Box>
1786
+ ) : null}
1415
1787
  <Box marginTop={1} flexDirection="row">
1416
1788
  <Text color="#7aa2ff">❯ </Text>
1417
1789
  <TextInput
1418
1790
  value={prompt}
1419
- onChange={setPrompt}
1420
1791
  onSubmit={(value) => {
1421
1792
  setPrompt("");
1422
1793
  void sendPrompt(value);
1423
1794
  }}
1424
- placeholder={voiceEnabled ? "Ask Astra... (/voice start for live transcription)" : "Ask Astra..."}
1795
+ onChange={(value) => {
1796
+ if (!voiceListening) {
1797
+ setPrompt(value);
1798
+ }
1799
+ }}
1800
+ placeholder={voiceEnabled ? "Ask Astra... (voice on: auto listen + send on silence)" : "Ask Astra..."}
1425
1801
  />
1426
1802
  </Box>
1427
1803
  </Box>