@astra-code/astra-ai 0.1.0 → 0.1.1

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") {
@@ -77,6 +99,7 @@ const eventToToolLine = (event: AgentEvent): string | null => {
77
99
  const output = String(
78
100
  (payload.output as string | undefined) ??
79
101
  (payload.content as string | undefined) ??
102
+ (payload.summary as string | undefined) ??
80
103
  event.error ??
81
104
  ""
82
105
  );
@@ -94,6 +117,28 @@ const eventToToolLine = (event: AgentEvent): string | null => {
94
117
  return null;
95
118
  };
96
119
 
120
+ const extractSnippetLines = (content: string, maxLines = TOOL_SNIPPET_LINES): string[] => {
121
+ if (!content) {
122
+ return [];
123
+ }
124
+ const lines = content.split("\n").map((line) => line.trimEnd());
125
+ if (lines.length <= maxLines) {
126
+ return lines;
127
+ }
128
+ return lines.slice(0, maxLines);
129
+ };
130
+
131
+ const extractDiffSnippet = (diff: unknown, maxLines = TOOL_SNIPPET_LINES): string[] => {
132
+ if (!Array.isArray(diff)) {
133
+ return [];
134
+ }
135
+ const interesting = diff
136
+ .map((line) => String(line))
137
+ .filter((line) => line.startsWith("+") || line.startsWith("-"))
138
+ .map((line) => line.slice(0, 220));
139
+ return interesting.slice(0, maxLines);
140
+ };
141
+
97
142
  const looksLikeLocalFilesystemClaim = (text: string): boolean => {
98
143
  const lower = text.toLowerCase();
99
144
  const changeWord =
@@ -518,6 +563,8 @@ export const AstraApp = (): React.JSX.Element => {
518
563
  const [streamingText, setStreamingText] = useState("");
519
564
  const [voiceEnabled, setVoiceEnabled] = useState(false);
520
565
  const [voiceListening, setVoiceListening] = useState(false);
566
+ const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
567
+ const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
521
568
  const [historyOpen, setHistoryOpen] = useState(false);
522
569
  const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
523
570
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -526,11 +573,18 @@ export const AstraApp = (): React.JSX.Element => {
526
573
  const [historyRows, setHistoryRows] = useState<SessionSummary[]>([]);
527
574
  const [historyIndex, setHistoryIndex] = useState(0);
528
575
  const liveVoiceRef = useRef<LiveTranscriptionController | null>(null);
576
+ const voiceSilenceTimerRef = useRef<NodeJS.Timeout | null>(null);
577
+ const fileEditBuffersRef = useRef<Map<string, {path: string; chunks: string[]; toolName?: string}>>(new Map());
578
+ const isSuperAdmin = user?.role === "super_admin";
529
579
 
530
580
  const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
531
581
  setMessages((prev) => [...prev, {kind, text}].slice(-300));
532
582
  }, []);
533
583
 
584
+ const pushToolCard = useCallback((card: ToolCard) => {
585
+ setMessages((prev) => [...prev, {kind: "tool", text: card.summary, card} satisfies UiMessage].slice(-300));
586
+ }, []);
587
+
534
588
  const filteredHistory = useMemo(() => {
535
589
  const q = historyQuery.trim().toLowerCase();
536
590
  if (!q) {
@@ -597,37 +651,70 @@ export const AstraApp = (): React.JSX.Element => {
597
651
  }, [backend, pushMessage, user]);
598
652
 
599
653
  const stopLiveVoice = useCallback(async (): Promise<void> => {
654
+ if (voiceSilenceTimerRef.current) {
655
+ clearTimeout(voiceSilenceTimerRef.current);
656
+ voiceSilenceTimerRef.current = null;
657
+ }
600
658
  const controller = liveVoiceRef.current;
601
659
  if (!controller) {
660
+ setVoiceWaitingForSilence(false);
602
661
  return;
603
662
  }
604
663
  liveVoiceRef.current = null;
605
664
  await controller.stop();
606
665
  setVoiceListening(false);
666
+ setVoiceWaitingForSilence(false);
607
667
  }, []);
608
668
 
609
- const startLiveVoice = useCallback((): void => {
669
+ const startLiveVoice = useCallback((announce = true): void => {
610
670
  if (liveVoiceRef.current) {
611
671
  return;
612
672
  }
613
673
  setVoiceEnabled(true);
614
674
  setVoiceListening(true);
615
- pushMessage("system", "Live transcription started. Speak now…");
675
+ setVoiceWaitingForSilence(false);
676
+ if (announce) {
677
+ pushMessage("system", "Voice input started. Speak now…");
678
+ }
616
679
  liveVoiceRef.current = startLiveTranscription({
617
680
  onPartial: (text) => {
618
681
  setPrompt(text);
682
+ if (voiceSilenceTimerRef.current) {
683
+ clearTimeout(voiceSilenceTimerRef.current);
684
+ }
685
+ const candidate = text.trim();
686
+ if (!candidate) {
687
+ return;
688
+ }
689
+ setVoiceWaitingForSilence(true);
690
+ voiceSilenceTimerRef.current = setTimeout(() => {
691
+ setVoiceQueuedPrompt(candidate);
692
+ void stopLiveVoice();
693
+ }, VOICE_SILENCE_MS);
619
694
  },
620
695
  onFinal: (text) => {
696
+ if (voiceSilenceTimerRef.current) {
697
+ clearTimeout(voiceSilenceTimerRef.current);
698
+ voiceSilenceTimerRef.current = null;
699
+ }
621
700
  setPrompt(text);
701
+ liveVoiceRef.current = null;
702
+ setVoiceListening(false);
703
+ setVoiceWaitingForSilence(false);
622
704
  },
623
705
  onError: (error) => {
706
+ setVoiceWaitingForSilence(false);
624
707
  pushMessage("error", `Voice transcription error: ${error.message}`);
625
708
  }
626
709
  });
627
- }, [pushMessage]);
710
+ }, [pushMessage, stopLiveVoice]);
628
711
 
629
712
  useEffect(() => {
630
713
  return () => {
714
+ if (voiceSilenceTimerRef.current) {
715
+ clearTimeout(voiceSilenceTimerRef.current);
716
+ voiceSilenceTimerRef.current = null;
717
+ }
631
718
  const controller = liveVoiceRef.current;
632
719
  if (controller) {
633
720
  void controller.stop();
@@ -635,6 +722,13 @@ export const AstraApp = (): React.JSX.Element => {
635
722
  };
636
723
  }, []);
637
724
 
725
+ useEffect(() => {
726
+ if (!voiceEnabled || !user || thinking || voiceListening || liveVoiceRef.current) {
727
+ return;
728
+ }
729
+ startLiveVoice(false);
730
+ }, [startLiveVoice, thinking, user, voiceEnabled, voiceListening]);
731
+
638
732
  useEffect(() => {
639
733
  if (!trustedWorkspace) {
640
734
  return;
@@ -652,6 +746,7 @@ export const AstraApp = (): React.JSX.Element => {
652
746
 
653
747
  const cached = loadSession();
654
748
  if (!cached) {
749
+ backend.setAuthSession(null);
655
750
  if (!cancelled) {
656
751
  setBooting(false);
657
752
  }
@@ -661,8 +756,12 @@ export const AstraApp = (): React.JSX.Element => {
661
756
  const valid = await backend.validateSession(cached);
662
757
  if (!valid) {
663
758
  clearSession();
759
+ backend.setAuthSession(null);
664
760
  } else if (!cancelled) {
665
- setUser(cached);
761
+ const hydrated = await backend.getUserProfile(cached).catch(() => cached);
762
+ saveSession(hydrated);
763
+ backend.setAuthSession(hydrated);
764
+ setUser(hydrated);
666
765
  setMessages([
667
766
  {kind: "system", text: "Welcome back. Type a message or /help."},
668
767
  {kind: "system", text: ""}
@@ -796,7 +895,7 @@ export const AstraApp = (): React.JSX.Element => {
796
895
  try {
797
896
  await backend.deleteSession(selected.id);
798
897
  setHistoryRows((prev) => prev.filter((row) => row.id !== selected.id));
799
- pushMessage("tool", `Archived session: ${selected.title}`);
898
+ pushMessage("system", `Archived session: ${selected.title}`);
800
899
  } catch (error) {
801
900
  pushMessage("error", `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`);
802
901
  }
@@ -833,8 +932,10 @@ export const AstraApp = (): React.JSX.Element => {
833
932
  }
834
933
 
835
934
  const authSession = data as AuthSession;
836
- saveSession(authSession);
837
- setUser(authSession);
935
+ const hydrated = await backend.getUserProfile(authSession).catch(() => authSession);
936
+ saveSession(hydrated);
937
+ backend.setAuthSession(hydrated);
938
+ setUser(hydrated);
838
939
  setEmail("");
839
940
  setPassword("");
840
941
  setMessages([
@@ -850,8 +951,91 @@ export const AstraApp = (): React.JSX.Element => {
850
951
 
851
952
  const handleEvent = useCallback(
852
953
  async (event: AgentEvent, activeSessionId: string): Promise<string | null> => {
954
+ if (event.type === "file_edit_start") {
955
+ const data = (event.data ?? {}) as Record<string, unknown>;
956
+ const toolId =
957
+ typeof event.tool_id === "string"
958
+ ? event.tool_id
959
+ : typeof data.tool_id === "string"
960
+ ? data.tool_id
961
+ : "";
962
+ const toolName =
963
+ typeof event.tool_name === "string"
964
+ ? event.tool_name
965
+ : typeof data.tool_name === "string"
966
+ ? data.tool_name
967
+ : undefined;
968
+ if (toolId) {
969
+ const current = fileEditBuffersRef.current.get(toolId) ?? {path: "(unknown)", chunks: []};
970
+ fileEditBuffersRef.current.set(toolId, {...current, toolName});
971
+ }
972
+ return null;
973
+ }
974
+
975
+ if (event.type === "file_edit_path") {
976
+ const data = (event.data ?? {}) as Record<string, unknown>;
977
+ const toolId =
978
+ typeof event.tool_id === "string"
979
+ ? event.tool_id
980
+ : typeof data.tool_id === "string"
981
+ ? data.tool_id
982
+ : "";
983
+ const path =
984
+ typeof event.path === "string"
985
+ ? event.path
986
+ : typeof data.path === "string"
987
+ ? data.path
988
+ : "(unknown)";
989
+ const toolName =
990
+ typeof event.tool_name === "string"
991
+ ? event.tool_name
992
+ : typeof data.tool_name === "string"
993
+ ? data.tool_name
994
+ : undefined;
995
+ if (toolId) {
996
+ const current = fileEditBuffersRef.current.get(toolId);
997
+ fileEditBuffersRef.current.set(toolId, {path, chunks: [], toolName: toolName ?? current?.toolName});
998
+ }
999
+ if (!isSuperAdmin) {
1000
+ const resolvedToolName = toolName ?? fileEditBuffersRef.current.get(toolId)?.toolName;
1001
+ const usingBulkWriter = resolvedToolName === "bulk_file_writer";
1002
+ pushToolCard({
1003
+ kind: "fileEdit",
1004
+ toolName: resolvedToolName ?? "edit_file",
1005
+ locality: "LOCAL",
1006
+ summary: usingBulkWriter ? `Writing file ${path}` : `Editing ${path}`,
1007
+ path
1008
+ });
1009
+ }
1010
+ return null;
1011
+ }
1012
+
1013
+ if (event.type === "file_edit_delta") {
1014
+ const data = (event.data ?? {}) as Record<string, unknown>;
1015
+ const toolId =
1016
+ typeof event.tool_id === "string"
1017
+ ? event.tool_id
1018
+ : typeof data.tool_id === "string"
1019
+ ? data.tool_id
1020
+ : "";
1021
+ const chunk = typeof event.content === "string" ? event.content : "";
1022
+ if (toolId && chunk) {
1023
+ const current = fileEditBuffersRef.current.get(toolId) ?? {path: "(unknown)", chunks: [], toolName: undefined};
1024
+ current.chunks.push(chunk);
1025
+ fileEditBuffersRef.current.set(toolId, current);
1026
+ }
1027
+ if (isSuperAdmin) {
1028
+ const raw = JSON.stringify(event);
1029
+ pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
1030
+ }
1031
+ return null;
1032
+ }
1033
+
853
1034
  const assistantPiece = extractAssistantText(event);
854
- if (assistantPiece && !["tool_result", "error", "credits_update", "credits_exhausted"].includes(event.type)) {
1035
+ if (
1036
+ assistantPiece &&
1037
+ !["tool_result", "error", "credits_update", "credits_exhausted", "file_edit_delta", "file_edit_path"].includes(event.type)
1038
+ ) {
855
1039
  return assistantPiece;
856
1040
  }
857
1041
 
@@ -872,22 +1056,45 @@ export const AstraApp = (): React.JSX.Element => {
872
1056
  });
873
1057
  }
874
1058
 
875
- pushMessage("tool", `[LOCAL] ▸ ran: ${command.slice(0, 60)}...`);
1059
+ if (isSuperAdmin) {
1060
+ pushMessage("tool", `[LOCAL] ▸ ran: ${command.slice(0, 60)}...`);
1061
+ } else {
1062
+ pushToolCard({
1063
+ kind: "terminal",
1064
+ toolName: "run_in_terminal",
1065
+ locality: "LOCAL",
1066
+ summary: `Running command: ${command.slice(0, 80)}`
1067
+ });
1068
+ }
876
1069
  return null;
877
1070
  }
878
1071
 
879
1072
  // Apply file operations to the local workspace immediately.
880
1073
  if (event.type === "tool_result" && event.success) {
881
1074
  const d = (event.data ?? {}) as Record<string, unknown>;
882
- const resultType = (event as Record<string, unknown>).result_type as string | undefined;
1075
+ const resultType = event.result_type;
1076
+ const toolResultId = typeof event.tool_call_id === "string" ? event.tool_call_id : "";
883
1077
  const relPath = typeof d.path === "string" ? d.path : null;
884
1078
  const lang = typeof d.language === "string" ? d.language : "plaintext";
1079
+ const locality: "LOCAL" | "REMOTE" = d.local === true ? "LOCAL" : "REMOTE";
885
1080
 
886
1081
  if (relPath) {
887
1082
  if (resultType === "file_create") {
888
1083
  const content = typeof d.content === "string" ? d.content : "";
889
1084
  writeLocalFile(relPath, content, lang);
890
- pushMessage("tool", `[LOCAL] wrote ${relPath}`);
1085
+ const snippetLines = extractSnippetLines(content);
1086
+ if (isSuperAdmin) {
1087
+ pushMessage("tool", `[LOCAL] ✓ wrote ${relPath}`);
1088
+ } else {
1089
+ pushToolCard({
1090
+ kind: "fileCreate",
1091
+ toolName: "create_file",
1092
+ locality,
1093
+ summary: `Created ${relPath}`,
1094
+ path: relPath,
1095
+ snippetLines
1096
+ });
1097
+ }
891
1098
  } else if (resultType === "file_edit") {
892
1099
  const content =
893
1100
  typeof d.full_new_content === "string"
@@ -897,13 +1104,61 @@ export const AstraApp = (): React.JSX.Element => {
897
1104
  : null;
898
1105
  if (content !== null) {
899
1106
  writeLocalFile(relPath, content, lang);
900
- pushMessage("tool", `[LOCAL] edited ${relPath}`);
1107
+ const snippetLines = extractDiffSnippet(d.diff) ?? extractSnippetLines(content);
1108
+ if (isSuperAdmin) {
1109
+ pushMessage("tool", `[LOCAL] ✓ edited ${relPath}`);
1110
+ } else {
1111
+ const toolId = toolResultId;
1112
+ const streamed = toolId ? fileEditBuffersRef.current.get(toolId) : undefined;
1113
+ const streamedSnippet = streamed ? extractSnippetLines(streamed.chunks.join("")) : [];
1114
+ pushToolCard({
1115
+ kind: "fileEdit",
1116
+ toolName: "edit_file",
1117
+ locality,
1118
+ summary: `Updated ${relPath}`,
1119
+ path: relPath,
1120
+ snippetLines: streamedSnippet.length > 0 ? streamedSnippet : snippetLines
1121
+ });
1122
+ if (toolId) {
1123
+ fileEditBuffersRef.current.delete(toolId);
1124
+ }
1125
+ }
901
1126
  }
902
1127
  } else if (resultType === "file_delete") {
903
1128
  deleteLocalFile(relPath);
904
- pushMessage("tool", `[LOCAL] ✓ deleted ${relPath}`);
1129
+ if (isSuperAdmin) {
1130
+ pushMessage("tool", `[LOCAL] ✓ deleted ${relPath}`);
1131
+ } else {
1132
+ pushToolCard({
1133
+ kind: "fileDelete",
1134
+ toolName: "delete_file",
1135
+ locality,
1136
+ summary: `Deleted ${relPath}`,
1137
+ path: relPath
1138
+ });
1139
+ }
905
1140
  }
906
1141
  }
1142
+
1143
+ if (!isSuperAdmin && event.tool_name === "start_preview") {
1144
+ const message =
1145
+ typeof d.message === "string"
1146
+ ? d.message
1147
+ : typeof d.preview_url === "string"
1148
+ ? `Preview: ${d.preview_url}`
1149
+ : "Local preview started.";
1150
+ pushToolCard({
1151
+ kind: "preview",
1152
+ toolName: "start_preview",
1153
+ locality,
1154
+ summary: message
1155
+ });
1156
+ return null;
1157
+ }
1158
+
1159
+ if (toolResultId) {
1160
+ fileEditBuffersRef.current.delete(toolResultId);
1161
+ }
907
1162
  }
908
1163
 
909
1164
  if (event.type === "credits_update") {
@@ -922,20 +1177,49 @@ export const AstraApp = (): React.JSX.Element => {
922
1177
  setCreditsRemaining(0);
923
1178
  }
924
1179
 
1180
+ if (!isSuperAdmin && NOISY_EVENT_TYPES.has(event.type)) {
1181
+ return null;
1182
+ }
1183
+
1184
+ if (!isSuperAdmin && event.type === "tool_start") {
1185
+ const tool = event.tool as {name?: string} | undefined;
1186
+ const name = tool?.name ?? "tool";
1187
+ pushToolCard({
1188
+ kind: "start",
1189
+ toolName: name,
1190
+ locality: "REMOTE",
1191
+ summary: `${name} is running...`
1192
+ });
1193
+ return null;
1194
+ }
1195
+
925
1196
  const toolLine = eventToToolLine(event);
926
1197
  if (toolLine) {
927
1198
  if (event.type === "error" || event.type === "credits_exhausted") {
928
1199
  pushMessage("error", toolLine);
929
1200
  } else {
930
- pushMessage("tool", toolLine);
1201
+ if (isSuperAdmin) {
1202
+ pushMessage("tool", toolLine);
1203
+ } else if (event.type === "tool_result") {
1204
+ const mark = event.success ? "completed" : "failed";
1205
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "tool";
1206
+ pushToolCard({
1207
+ kind: event.success ? "success" : "error",
1208
+ toolName,
1209
+ locality: ((event.data ?? {}) as Record<string, unknown>).local === true ? "LOCAL" : "REMOTE",
1210
+ summary: `${toolName} ${mark}`
1211
+ });
1212
+ }
931
1213
  }
932
1214
  } else if (event.type !== "thinking") {
933
- const raw = JSON.stringify(event);
934
- pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
1215
+ if (isSuperAdmin) {
1216
+ const raw = JSON.stringify(event);
1217
+ pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
1218
+ }
935
1219
  }
936
1220
  return null;
937
1221
  },
938
- [backend, deleteLocalFile, pushMessage, writeLocalFile]
1222
+ [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, writeLocalFile]
939
1223
  );
940
1224
 
941
1225
  const sendPrompt = useCallback(
@@ -948,7 +1232,7 @@ export const AstraApp = (): React.JSX.Element => {
948
1232
  if (text === "/help") {
949
1233
  pushMessage(
950
1234
  "system",
951
- "/new /history /voice on|off|status|start|stop|input /settings /settings model <id> /logout /exit"
1235
+ "/new /history /voice /voice on|off|status /settings /settings model <id> /logout /exit"
952
1236
  );
953
1237
  pushMessage("system", "");
954
1238
  return;
@@ -956,7 +1240,20 @@ export const AstraApp = (): React.JSX.Element => {
956
1240
  if (text === "/settings") {
957
1241
  pushMessage(
958
1242
  "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()}`
1243
+ `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()}`
1244
+ );
1245
+ pushMessage("system", "");
1246
+ return;
1247
+ }
1248
+ if (text === "/voice") {
1249
+ if (!voiceEnabled) {
1250
+ setVoiceEnabled(true);
1251
+ startLiveVoice(true);
1252
+ return;
1253
+ }
1254
+ pushMessage(
1255
+ "system",
1256
+ `Voice input is on${voiceListening ? " (currently listening)" : ""}. Use /voice off to disable.`
960
1257
  );
961
1258
  pushMessage("system", "");
962
1259
  return;
@@ -984,39 +1281,22 @@ export const AstraApp = (): React.JSX.Element => {
984
1281
  if (text === "/voice status") {
985
1282
  pushMessage(
986
1283
  "system",
987
- `Voice mode is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}.`
1284
+ `Voice input is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}${voiceWaitingForSilence ? " (waiting for silence to auto-send)" : ""}.`
988
1285
  );
989
1286
  pushMessage("system", "");
990
1287
  return;
991
1288
  }
992
1289
  if (text === "/voice on") {
993
1290
  setVoiceEnabled(true);
994
- pushMessage("system", "Voice mode enabled (assistant responses will be spoken).");
1291
+ startLiveVoice(true);
1292
+ pushMessage("system", `Voice input enabled. Auto-send after ${Math.round(VOICE_SILENCE_MS / 1000)}s silence.`);
995
1293
  pushMessage("system", "");
996
1294
  return;
997
1295
  }
998
1296
  if (text === "/voice off") {
999
1297
  await stopLiveVoice();
1000
1298
  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.");
1299
+ pushMessage("system", "Voice input disabled.");
1020
1300
  pushMessage("system", "");
1021
1301
  return;
1022
1302
  }
@@ -1025,13 +1305,11 @@ export const AstraApp = (): React.JSX.Element => {
1025
1305
  if (!transcribed) {
1026
1306
  pushMessage(
1027
1307
  "error",
1028
- "No speech transcribed. Set ASTRA_STT_COMMAND to a command that prints transcript text to stdout."
1308
+ "No speech transcribed. Check OPENAI_API_KEY and mic capture command (optional ASTRA_STT_CAPTURE_COMMAND)."
1029
1309
  );
1030
1310
  return;
1031
1311
  }
1032
- pushMessage("tool", `[LOCAL] 🎙 ${transcribed}`);
1033
- setPrompt(transcribed);
1034
- pushMessage("system", "Transcribed input ready. Press Enter to send.");
1312
+ setVoiceQueuedPrompt(transcribed.trim());
1035
1313
  return;
1036
1314
  }
1037
1315
  if (text === "/new") {
@@ -1045,6 +1323,7 @@ export const AstraApp = (): React.JSX.Element => {
1045
1323
  if (text === "/logout") {
1046
1324
  await stopLiveVoice();
1047
1325
  clearSession();
1326
+ backend.setAuthSession(null);
1048
1327
  setUser(null);
1049
1328
  setMessages([]);
1050
1329
  setChatMessages([]);
@@ -1113,9 +1392,6 @@ export const AstraApp = (): React.JSX.Element => {
1113
1392
  ? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
1114
1393
  : cleanedAssistant;
1115
1394
  pushMessage("assistant", guardedAssistant);
1116
- if (voiceEnabled) {
1117
- speakText(guardedAssistant);
1118
- }
1119
1395
  setChatMessages((prev) => [...prev, {role: "assistant", content: cleanedAssistant}]);
1120
1396
  } else {
1121
1397
  setChatMessages((prev) => [...prev, {role: "assistant", content: assistant}]);
@@ -1143,10 +1419,25 @@ export const AstraApp = (): React.JSX.Element => {
1143
1419
  user,
1144
1420
  voiceEnabled,
1145
1421
  voiceListening,
1422
+ voiceWaitingForSilence,
1146
1423
  workspaceRoot
1147
1424
  ]
1148
1425
  );
1149
1426
 
1427
+ useEffect(() => {
1428
+ if (!voiceQueuedPrompt || !user || thinking) {
1429
+ return;
1430
+ }
1431
+ const queued = voiceQueuedPrompt.trim();
1432
+ setVoiceQueuedPrompt(null);
1433
+ if (!queued) {
1434
+ return;
1435
+ }
1436
+ pushMessage("system", `Voice input: ${queued}`);
1437
+ setPrompt("");
1438
+ void sendPrompt(queued);
1439
+ }, [pushMessage, sendPrompt, thinking, user, voiceQueuedPrompt]);
1440
+
1150
1441
  if (!trustedWorkspace) {
1151
1442
  return (
1152
1443
  <Box flexDirection="column">
@@ -1363,12 +1654,12 @@ export const AstraApp = (): React.JSX.Element => {
1363
1654
  <Box flexDirection="row">
1364
1655
  <Text color="#4a6070">voice </Text>
1365
1656
  <Text color={voiceEnabled ? "#9ad5ff" : "#5a7a9a"}>
1366
- {voiceEnabled ? (voiceListening ? "on/listening" : "on") : "off"}
1657
+ {voiceEnabled ? (voiceListening ? (voiceWaitingForSilence ? "on/waiting" : "on/listening") : "on") : "off"}
1367
1658
  </Text>
1368
1659
  </Box>
1369
1660
  </Box>
1370
1661
  <Text color="#2a3a50">{DIVIDER}</Text>
1371
- <Text color="#3a5068">/help /new /history /voice on|off|status|start|stop|input /settings /logout /exit</Text>
1662
+ <Text color="#3a5068">/help /new /history /voice /voice on|off|status /settings /logout /exit</Text>
1372
1663
  <Text color="#2a3a50">{DIVIDER}</Text>
1373
1664
  <Box flexDirection="column" marginTop={1}>
1374
1665
  {messages.map((message, index) => {
@@ -1378,6 +1669,55 @@ export const AstraApp = (): React.JSX.Element => {
1378
1669
  if (isSpacing) {
1379
1670
  return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1380
1671
  }
1672
+
1673
+ if (message.kind === "tool" && message.card && !isSuperAdmin) {
1674
+ const card = message.card;
1675
+ const icon =
1676
+ card.kind === "error"
1677
+ ? "✕"
1678
+ : card.kind === "fileDelete"
1679
+ ? "🗑"
1680
+ : card.kind === "fileCreate"
1681
+ ? "+"
1682
+ : card.kind === "fileEdit"
1683
+ ? "✎"
1684
+ : card.kind === "preview"
1685
+ ? "◉"
1686
+ : card.kind === "terminal"
1687
+ ? "⌘"
1688
+ : card.kind === "start"
1689
+ ? "…"
1690
+ : "✓";
1691
+ const accent =
1692
+ card.kind === "error"
1693
+ ? "#ff8d8d"
1694
+ : card.kind === "fileDelete"
1695
+ ? "#8a96a6"
1696
+ : card.kind === "fileEdit"
1697
+ ? "#b7d4ff"
1698
+ : card.kind === "fileCreate"
1699
+ ? "#a5e9c5"
1700
+ : card.kind === "preview"
1701
+ ? "#9ad5ff"
1702
+ : "#9bc5ff";
1703
+ return (
1704
+ <Box key={`${index}-${message.kind}`} flexDirection="row">
1705
+ <Text color={style.labelColor}>{paddedLabel}</Text>
1706
+ <Box flexDirection="column">
1707
+ <Text color={accent}>
1708
+ {icon} {card.summary} <Text color="#5a7a9a">[{card.locality}]</Text>
1709
+ </Text>
1710
+ {card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
1711
+ {(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
1712
+ <Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
1713
+ {line}
1714
+ </Text>
1715
+ ))}
1716
+ </Box>
1717
+ </Box>
1718
+ );
1719
+ }
1720
+
1381
1721
  return (
1382
1722
  <Box key={`${index}-${message.kind}`} flexDirection="row">
1383
1723
  <Text color={style.labelColor} bold={style.bold}>
@@ -1412,16 +1752,32 @@ export const AstraApp = (): React.JSX.Element => {
1412
1752
  </Text>
1413
1753
  </Box>
1414
1754
  ) : null}
1755
+ {voiceEnabled && !thinking ? (
1756
+ <Box flexDirection="row" marginTop={1}>
1757
+ <Text color="#9ad5ff">{"🎤 voice".padEnd(LABEL_WIDTH, " ")}</Text>
1758
+ {voiceListening && !voiceWaitingForSilence ? (
1759
+ <Text color="#a6d9ff">🎙 listening... goblin ears activated 👂</Text>
1760
+ ) : voiceWaitingForSilence ? (
1761
+ <Text color="#b7c4d8">⏸ waiting for silence... dramatic pause loading...</Text>
1762
+ ) : (
1763
+ <Text color="#6f8199">voice armed... say something when ready</Text>
1764
+ )}
1765
+ </Box>
1766
+ ) : null}
1415
1767
  <Box marginTop={1} flexDirection="row">
1416
1768
  <Text color="#7aa2ff">❯ </Text>
1417
1769
  <TextInput
1418
1770
  value={prompt}
1419
- onChange={setPrompt}
1420
1771
  onSubmit={(value) => {
1421
1772
  setPrompt("");
1422
1773
  void sendPrompt(value);
1423
1774
  }}
1424
- placeholder={voiceEnabled ? "Ask Astra... (/voice start for live transcription)" : "Ask Astra..."}
1775
+ onChange={(value) => {
1776
+ if (!voiceListening) {
1777
+ setPrompt(value);
1778
+ }
1779
+ }}
1780
+ placeholder={voiceEnabled ? "Ask Astra... (voice on: auto listen + send on silence)" : "Ask Astra..."}
1425
1781
  />
1426
1782
  </Box>
1427
1783
  </Box>