@astra-code/astra-ai 0.1.5 → 0.1.7

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/dist/app/App.js CHANGED
@@ -4,7 +4,7 @@ import { Box, Text, useApp, useInput } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import TextInput from "ink-text-input";
6
6
  import { spawn } from "child_process";
7
- import { mkdirSync, unlinkSync, writeFileSync } from "fs";
7
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
8
8
  import { dirname, join } from "path";
9
9
  import { BackendClient } from "../lib/backendClient.js";
10
10
  import { clearSession, loadSession, saveSession } from "../lib/sessionStore.js";
@@ -12,7 +12,7 @@ import { getBackendUrl, getDefaultClientId, getDefaultModel, getProviderForModel
12
12
  import { runTerminalCommand } from "../lib/terminalBridge.js";
13
13
  import { isWorkspaceTrusted, trustWorkspace } from "../lib/trustStore.js";
14
14
  import { scanWorkspace } from "../lib/workspaceScanner.js";
15
- import { startLiveTranscription, transcribeOnce } from "../lib/voice.js";
15
+ import { startLiveTranscription, transcribeOnce, resolveAudioDevice, setAudioDevice, listAvfAudioDevices, writeAstraKey } from "../lib/voice.js";
16
16
  // const ASTRA_ASCII = `
17
17
  // █████╗ ███████╗████████╗██████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗
18
18
  // ██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
@@ -151,6 +151,14 @@ const extractAssistantText = (event) => {
151
151
  };
152
152
  const LABEL_WIDTH = 10;
153
153
  const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
154
+ const formatTime = (ts) => {
155
+ const d = new Date(ts);
156
+ const raw = d.getHours();
157
+ const ampm = raw >= 12 ? "pm" : "am";
158
+ const h = raw % 12 === 0 ? 12 : raw % 12;
159
+ const m = d.getMinutes().toString().padStart(2, "0");
160
+ return `${h}:${m} ${ampm}`;
161
+ };
154
162
  const MIN_DIVIDER = 64;
155
163
  const MAX_DIVIDER = 120;
156
164
  const styleForKind = (kind) => {
@@ -193,7 +201,13 @@ const normalizeAssistantText = (input) => {
193
201
  // Remove control chars but preserve newlines/tabs.
194
202
  .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
195
203
  .replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
196
- .replace(/([.!?])([A-Za-z])/g, "$1 $2")
204
+ // Add space after sentence-ending punctuation only when followed by an
205
+ // uppercase letter (sentence start). Using [A-Za-z] here would break
206
+ // file extensions like .css, .json, .jsx, .tsx — those always start with
207
+ // a lowercase letter.
208
+ .replace(/([.!?])([A-Z])/g, "$1 $2")
209
+ // For ! and ? followed by lowercase, also add a space (natural English).
210
+ .replace(/([!?])([a-z])/g, "$1 $2")
197
211
  .replace(/([a-z])(\u2022)/g, "$1\n$2")
198
212
  .replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
199
213
  .replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
@@ -220,34 +234,56 @@ const normalizeAssistantText = (input) => {
220
234
  }
221
235
  return deduped.join("\n\n").trim();
222
236
  };
237
+ const guessDevUrl = (command) => {
238
+ // Extract an explicit --port or -p value from the command.
239
+ const portMatch = command.match(/(?:--port|-p)\s+(\d+)/) ?? command.match(/(?:--port=|-p=)(\d+)/);
240
+ if (portMatch) {
241
+ return `http://localhost:${portMatch[1]}`;
242
+ }
243
+ // Default ports by framework.
244
+ if (/next/.test(command))
245
+ return "http://localhost:3000";
246
+ if (/vite|vue/.test(command))
247
+ return "http://localhost:5173";
248
+ if (/remix/.test(command))
249
+ return "http://localhost:3000";
250
+ if (/astro/.test(command))
251
+ return "http://localhost:4321";
252
+ if (/angular|ng\s+serve/.test(command))
253
+ return "http://localhost:4200";
254
+ if (/npm\s+run\s+dev|npm\s+start|npx\s+react-scripts/.test(command))
255
+ return "http://localhost:3000";
256
+ return null;
257
+ };
223
258
  const summarizeToolResult = (toolName, data, success) => {
224
259
  if (!success) {
225
- return `${toolName} failed`;
260
+ return { summary: `${toolName} failed` };
226
261
  }
227
262
  const path = typeof data.path === "string" ? data.path : "";
228
263
  const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
229
264
  if (toolName === "view_file") {
230
- if (totalLines !== null && path) {
231
- return `Read ${totalLines} lines of <${path}>`;
232
- }
233
- if (path) {
234
- return `Read <${path}>`;
235
- }
265
+ const result = {
266
+ summary: totalLines !== null ? `Read ${totalLines} lines` : "Read file",
267
+ };
268
+ if (path)
269
+ result.path = path;
270
+ return result;
236
271
  }
237
272
  if (toolName === "list_directory") {
238
- const dir = path || ".";
239
- if (totalLines !== null) {
240
- return `Listed ${totalLines} entries in <${dir}>`;
241
- }
242
- return `Listed <${dir}>`;
273
+ return {
274
+ summary: totalLines !== null ? `Listed ${totalLines} entries` : "Listed directory",
275
+ path: path || ".",
276
+ };
243
277
  }
244
278
  if (toolName === "semantic_search") {
245
- return "Searched codebase context";
279
+ return { summary: "Searched codebase context" };
246
280
  }
247
281
  if (toolName === "search_files") {
248
- return totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths";
282
+ return {
283
+ summary: totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths",
284
+ };
249
285
  }
250
- return `${toolName} completed`;
286
+ return { summary: `${toolName} completed` };
251
287
  };
252
288
  const isLikelyVoiceNoise = (text) => {
253
289
  const normalized = text.trim().toLowerCase();
@@ -442,9 +478,17 @@ export const AstraApp = () => {
442
478
  const welcomeLine = dividerWidth < 96 ? "Welcome to Astra" : FOUNDER_WELCOME;
443
479
  const backend = useMemo(() => new BackendClient(), []);
444
480
  const { exit } = useApp();
481
+ // Keep backend token fresh: when the client auto-refreshes on 401, persist and update state.
482
+ useEffect(() => {
483
+ backend.setOnTokenRefreshed((refreshed) => {
484
+ saveSession(refreshed);
485
+ setUser(refreshed);
486
+ });
487
+ }, [backend]);
445
488
  // In-session file cache: tracks files created/edited so subsequent requests
446
489
  // include their latest content in workspaceFiles (VirtualFS stays up to date).
447
490
  const localFileCache = useRef(new Map());
491
+ const abortRunRef = useRef(null);
448
492
  const writeLocalFile = useCallback((relPath, content, language) => {
449
493
  try {
450
494
  const abs = join(workspaceRoot, relPath);
@@ -483,6 +527,7 @@ export const AstraApp = () => {
483
527
  const [activeModel, setActiveModel] = useState(getDefaultModel());
484
528
  const [creditsRemaining, setCreditsRemaining] = useState(null);
485
529
  const [lastCreditCost, setLastCreditCost] = useState(null);
530
+ const [loopCtx, setLoopCtx] = useState(null);
486
531
  const runtimeMode = getRuntimeMode();
487
532
  const [prompt, setPrompt] = useState("");
488
533
  const [thinking, setThinking] = useState(false);
@@ -492,7 +537,9 @@ export const AstraApp = () => {
492
537
  const [voicePreparing, setVoicePreparing] = useState(false);
493
538
  const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
494
539
  const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState(null);
540
+ const [micSetupDevices, setMicSetupDevices] = useState(null);
495
541
  const [toolFeedMode, setToolFeedMode] = useState("compact");
542
+ const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
496
543
  const [historyOpen, setHistoryOpen] = useState(false);
497
544
  const [historyMode, setHistoryMode] = useState("picker");
498
545
  const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
@@ -507,11 +554,11 @@ export const AstraApp = () => {
507
554
  const fileEditBuffersRef = useRef(new Map());
508
555
  const isSuperAdmin = user?.role === "super_admin";
509
556
  const pushMessage = useCallback((kind, text) => {
510
- setMessages((prev) => [...prev, { kind, text }].slice(-300));
557
+ setMessages((prev) => [...prev, { kind, text, ts: Date.now() }].slice(-300));
511
558
  }, []);
512
559
  const pushToolCard = useCallback((card) => {
513
560
  setMessages((prev) => {
514
- const nextEntry = { kind: "tool", text: card.summary, card };
561
+ const nextEntry = { kind: "tool", text: card.summary, card, ts: Date.now() };
515
562
  const last = prev[prev.length - 1];
516
563
  if (last &&
517
564
  last.kind === "tool" &&
@@ -624,64 +671,86 @@ export const AstraApp = () => {
624
671
  if (announce) {
625
672
  pushMessage("system", "Voice input armed. Preparing microphone...");
626
673
  }
627
- liveVoiceRef.current = startLiveTranscription({
628
- onPartial: (text) => {
674
+ // Resolve mic device before starting — triggers onboarding if not configured.
675
+ void resolveAudioDevice(workspaceRoot).then((device) => {
676
+ if (device === null) {
677
+ // No device configured — run onboarding inline.
629
678
  setVoicePreparing(false);
630
- setVoiceListening(true);
631
- if (voiceSilenceTimerRef.current) {
632
- clearTimeout(voiceSilenceTimerRef.current);
633
- }
634
- const candidate = text.trim();
635
- if (!candidate) {
636
- setPrompt("");
637
- setVoiceWaitingForSilence(false);
679
+ const devices = listAvfAudioDevices();
680
+ if (!devices.length) {
681
+ pushMessage("error", "No audio devices found. Install ffmpeg: brew install ffmpeg");
682
+ setVoiceEnabled(false);
638
683
  return;
639
684
  }
640
- const normalized = candidate.toLowerCase();
641
- const isLikelyNoise = isLikelyVoiceNoise(normalized);
642
- // #region agent log
643
- 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(() => { });
644
- // #endregion
645
- if (isLikelyNoise) {
646
- setPrompt("");
685
+ setMicSetupDevices(devices);
686
+ const lines = [
687
+ "Let's set up your microphone first.",
688
+ ...devices.map(d => ` [${d.index}] ${d.name}`),
689
+ "Type the number for your mic and press Enter:"
690
+ ];
691
+ pushMessage("system", lines.join("\n"));
692
+ return;
693
+ }
694
+ // Device resolved — start transcription.
695
+ liveVoiceRef.current = startLiveTranscription({
696
+ onPartial: (text) => {
697
+ setVoicePreparing(false);
698
+ setVoiceListening(true);
699
+ if (voiceSilenceTimerRef.current) {
700
+ clearTimeout(voiceSilenceTimerRef.current);
701
+ }
702
+ const candidate = text.trim();
703
+ if (!candidate) {
704
+ setPrompt("");
705
+ setVoiceWaitingForSilence(false);
706
+ return;
707
+ }
708
+ const normalized = candidate.toLowerCase();
709
+ const isLikelyNoise = isLikelyVoiceNoise(normalized);
710
+ // #region agent log
711
+ 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(() => { });
712
+ // #endregion
713
+ if (isLikelyNoise) {
714
+ setPrompt("");
715
+ setVoiceWaitingForSilence(false);
716
+ return;
717
+ }
718
+ setPrompt(text);
719
+ setVoiceWaitingForSilence(true);
720
+ voiceSilenceTimerRef.current = setTimeout(() => {
721
+ // #region agent log
722
+ 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(() => { });
723
+ // #endregion
724
+ setVoiceQueuedPrompt(candidate);
725
+ void stopLiveVoice();
726
+ }, VOICE_SILENCE_MS);
727
+ },
728
+ onFinal: (text) => {
729
+ if (voiceSilenceTimerRef.current) {
730
+ clearTimeout(voiceSilenceTimerRef.current);
731
+ voiceSilenceTimerRef.current = null;
732
+ }
733
+ setPrompt(text);
734
+ liveVoiceRef.current = null;
735
+ setVoicePreparing(false);
736
+ setVoiceListening(false);
647
737
  setVoiceWaitingForSilence(false);
648
- return;
649
- }
650
- setPrompt(text);
651
- setVoiceWaitingForSilence(true);
652
- voiceSilenceTimerRef.current = setTimeout(() => {
653
738
  // #region agent log
654
- 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(() => { });
739
+ 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(() => { });
740
+ // #endregion
741
+ },
742
+ onError: (error) => {
743
+ setVoicePreparing(false);
744
+ setVoiceListening(false);
745
+ setVoiceWaitingForSilence(false);
746
+ pushMessage("error", `Voice transcription error: ${error.message}`);
747
+ // #region agent log
748
+ 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(() => { });
655
749
  // #endregion
656
- setVoiceQueuedPrompt(candidate);
657
- void stopLiveVoice();
658
- }, VOICE_SILENCE_MS);
659
- },
660
- onFinal: (text) => {
661
- if (voiceSilenceTimerRef.current) {
662
- clearTimeout(voiceSilenceTimerRef.current);
663
- voiceSilenceTimerRef.current = null;
664
750
  }
665
- setPrompt(text);
666
- liveVoiceRef.current = null;
667
- setVoicePreparing(false);
668
- setVoiceListening(false);
669
- setVoiceWaitingForSilence(false);
670
- // #region agent log
671
- 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(() => { });
672
- // #endregion
673
- },
674
- onError: (error) => {
675
- setVoicePreparing(false);
676
- setVoiceListening(false);
677
- setVoiceWaitingForSilence(false);
678
- pushMessage("error", `Voice transcription error: ${error.message}`);
679
- // #region agent log
680
- 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(() => { });
681
- // #endregion
682
- }
683
- });
684
- }, [pushMessage, stopLiveVoice]);
751
+ });
752
+ }); // end resolveAudioDevice.then
753
+ }, [pushMessage, stopLiveVoice, workspaceRoot]);
685
754
  useEffect(() => {
686
755
  return () => {
687
756
  if (voiceSilenceTimerRef.current) {
@@ -763,6 +832,13 @@ export const AstraApp = () => {
763
832
  if (key.return) {
764
833
  if (trustSelection === 0) {
765
834
  trustWorkspace(workspaceRoot);
835
+ // Create .astra settings file at workspace root if it doesn't exist yet.
836
+ try {
837
+ const astraPath = join(workspaceRoot, ".astra");
838
+ if (!existsSync(astraPath))
839
+ writeFileSync(astraPath, "");
840
+ }
841
+ catch { /* non-fatal */ }
766
842
  setTrustedWorkspace(true);
767
843
  setBooting(true);
768
844
  return;
@@ -776,6 +852,12 @@ export const AstraApp = () => {
776
852
  exit();
777
853
  return;
778
854
  }
855
+ if (key.escape && thinking) {
856
+ abortRunRef.current?.abort();
857
+ pushMessage("system", "Cancelled.");
858
+ pushMessage("system", "");
859
+ return;
860
+ }
779
861
  if (historyOpen) {
780
862
  if (key.escape) {
781
863
  if (historyMode === "sessions") {
@@ -1092,16 +1174,18 @@ export const AstraApp = () => {
1092
1174
  }
1093
1175
  }
1094
1176
  if (!isSuperAdmin && event.tool_name === "start_preview") {
1095
- const message = typeof d.message === "string"
1096
- ? d.message
1097
- : typeof d.preview_url === "string"
1098
- ? `Preview: ${d.preview_url}`
1099
- : "Local preview started.";
1177
+ // Server mode returns preview_url (tunnel). Desktop mode returns a
1178
+ // plain message — try to guess the local URL from the command.
1179
+ const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
1180
+ const command = typeof d.command === "string" ? d.command : "";
1181
+ const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
1182
+ const displayUrl = tunnelUrl ?? localUrl;
1100
1183
  pushToolCard({
1101
1184
  kind: "preview",
1102
1185
  toolName: "start_preview",
1103
1186
  locality,
1104
- summary: message
1187
+ summary: "Dev server running",
1188
+ ...(displayUrl ? { path: displayUrl } : {}),
1105
1189
  });
1106
1190
  return null;
1107
1191
  }
@@ -1146,6 +1230,16 @@ export const AstraApp = () => {
1146
1230
  pushMessage("system", "");
1147
1231
  }
1148
1232
  }
1233
+ if (event.type === "timing") {
1234
+ const ev = event;
1235
+ if (ev.phase === "llm_done") {
1236
+ const inTok = Number(ev.input_tokens ?? 0);
1237
+ const outTok = Number(ev.output_tokens ?? 0);
1238
+ if (inTok > 0 || outTok > 0)
1239
+ setLoopCtx({ in: inTok, out: outTok });
1240
+ }
1241
+ return null;
1242
+ }
1149
1243
  if (event.type === "continuation_check") {
1150
1244
  const recommendation = typeof event.recommendation === "string" && event.recommendation
1151
1245
  ? event.recommendation
@@ -1183,12 +1277,13 @@ export const AstraApp = () => {
1183
1277
  if (alreadyRepresented) {
1184
1278
  return null;
1185
1279
  }
1186
- const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
1280
+ const { summary, path: summaryPath } = summarizeToolResult(toolName, payload, Boolean(event.success));
1187
1281
  pushToolCard({
1188
1282
  kind: event.success ? "success" : "error",
1189
1283
  toolName,
1190
1284
  locality: payload.local === true ? "LOCAL" : "REMOTE",
1191
- summary: event.success ? summary : `${toolName} ${mark}`
1285
+ summary: event.success ? summary : `${toolName} ${mark}`,
1286
+ ...(summaryPath ? { path: summaryPath } : {}),
1192
1287
  });
1193
1288
  }
1194
1289
  }
@@ -1200,24 +1295,58 @@ export const AstraApp = () => {
1200
1295
  }
1201
1296
  }
1202
1297
  return null;
1203
- }, [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]);
1298
+ }, [backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]);
1204
1299
  const sendPrompt = useCallback(async (rawPrompt) => {
1205
1300
  const text = rawPrompt.trim();
1206
1301
  if (!text || !user || thinking) {
1207
1302
  return;
1208
1303
  }
1304
+ setPrompt("");
1305
+ // Mic onboarding: intercept when waiting for device selection.
1306
+ if (micSetupDevices !== null) {
1307
+ const idx = parseInt(text, 10);
1308
+ const valid = !isNaN(idx) && idx >= 0 && micSetupDevices.some(d => d.index === idx);
1309
+ if (!valid) {
1310
+ pushMessage("error", `Please type one of: ${micSetupDevices.map(d => d.index).join(", ")}`);
1311
+ return;
1312
+ }
1313
+ const device = `:${idx}`;
1314
+ // Write to .astra local cache
1315
+ writeAstraKey(workspaceRoot, "ASTRA_STT_DEVICE", device);
1316
+ // Persist to backend
1317
+ void backend.updateCliSettings({ audio_device_index: idx });
1318
+ // Update in-process cache
1319
+ setAudioDevice(device);
1320
+ setMicSetupDevices(null);
1321
+ pushMessage("system", `Mic set to [${idx}] ${micSetupDevices.find(d => d.index === idx)?.name ?? ""}. Starting voice...`);
1322
+ pushMessage("system", "");
1323
+ startLiveVoice(false);
1324
+ return;
1325
+ }
1209
1326
  if (text === "/help") {
1210
- pushMessage("system", "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit");
1327
+ pushMessage("system", isSuperAdmin
1328
+ ? "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
1329
+ : "/new /history /voice /dictate on|off|status /settings /settings model <id> /logout /exit");
1211
1330
  pushMessage("system", "");
1212
1331
  return;
1213
1332
  }
1214
1333
  if (text === "/tools compact") {
1334
+ if (!isSuperAdmin) {
1335
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1336
+ pushMessage("system", "");
1337
+ return;
1338
+ }
1215
1339
  setToolFeedMode("compact");
1216
1340
  pushMessage("system", "Tool feed set to compact.");
1217
1341
  pushMessage("system", "");
1218
1342
  return;
1219
1343
  }
1220
1344
  if (text === "/tools expanded") {
1345
+ if (!isSuperAdmin) {
1346
+ pushMessage("system", "Unknown command. Type /help for available commands.");
1347
+ pushMessage("system", "");
1348
+ return;
1349
+ }
1221
1350
  setToolFeedMode("expanded");
1222
1351
  pushMessage("system", "Tool feed set to expanded.");
1223
1352
  pushMessage("system", "");
@@ -1316,6 +1445,9 @@ export const AstraApp = () => {
1316
1445
  pushMessage("user", text);
1317
1446
  setThinking(true);
1318
1447
  setStreamingText("");
1448
+ setLoopCtx(null);
1449
+ const abortController = new AbortController();
1450
+ abortRunRef.current = abortController;
1319
1451
  try {
1320
1452
  // Scan the local workspace so the backend VirtualFS is populated.
1321
1453
  // Merge in any files created/edited during this session so edits
@@ -1324,8 +1456,25 @@ export const AstraApp = () => {
1324
1456
  const sessionFiles = Array.from(localFileCache.current.values());
1325
1457
  const seenPaths = new Set(sessionFiles.map((f) => f.path));
1326
1458
  const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
1327
- let assistant = "";
1459
+ // `pendingText` is text received since the last committed block.
1460
+ // It gets flushed to the messages list whenever tool activity starts,
1461
+ // keeping text and tool cards in the exact order they were emitted.
1462
+ let pendingText = "";
1463
+ let allAssistantText = "";
1328
1464
  let localActionConfirmed = false;
1465
+ const commitPending = (applyGuard = false) => {
1466
+ if (!pendingText.trim()) {
1467
+ pendingText = "";
1468
+ return;
1469
+ }
1470
+ const clean = normalizeAssistantText(pendingText);
1471
+ const msg = applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
1472
+ ? `Remote result (not yet confirmed as local filesystem change): ${clean}`
1473
+ : clean;
1474
+ pushMessage("assistant", msg);
1475
+ pendingText = "";
1476
+ setStreamingText("");
1477
+ };
1329
1478
  for await (const event of backend.streamChat({
1330
1479
  user,
1331
1480
  sessionId: activeSessionId,
@@ -1333,7 +1482,8 @@ export const AstraApp = () => {
1333
1482
  workspaceRoot,
1334
1483
  workspaceTree,
1335
1484
  workspaceFiles: mergedFiles,
1336
- model: activeModel
1485
+ model: activeModel,
1486
+ signal: abortController.signal
1337
1487
  })) {
1338
1488
  if (event.type === "run_in_terminal") {
1339
1489
  localActionConfirmed = true;
@@ -1347,30 +1497,37 @@ export const AstraApp = () => {
1347
1497
  if (event.type === "done") {
1348
1498
  break;
1349
1499
  }
1500
+ // Flush any accumulated text before the first tool event so that text
1501
+ // appears above the tool cards that follow it — preserving order.
1502
+ if (event.type === "tool_start" || event.type === "run_in_terminal") {
1503
+ commitPending();
1504
+ }
1350
1505
  const piece = await handleEvent(event, activeSessionId);
1351
1506
  if (piece) {
1352
- assistant += piece;
1353
- setStreamingText(normalizeAssistantText(assistant));
1507
+ pendingText += piece;
1508
+ allAssistantText += piece;
1509
+ setStreamingText(normalizeAssistantText(pendingText));
1354
1510
  }
1355
1511
  }
1356
1512
  setStreamingText("");
1357
- if (assistant.trim()) {
1358
- const cleanedAssistant = normalizeAssistantText(assistant);
1359
- const guardedAssistant = !localActionConfirmed && looksLikeLocalFilesystemClaim(cleanedAssistant)
1360
- ? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
1361
- : cleanedAssistant;
1362
- pushMessage("assistant", guardedAssistant);
1363
- setChatMessages((prev) => [...prev, { role: "assistant", content: cleanedAssistant }]);
1513
+ commitPending(true);
1514
+ // Update conversation history for the backend with the full combined text.
1515
+ if (allAssistantText.trim()) {
1516
+ setChatMessages((prev) => [...prev, { role: "assistant", content: normalizeAssistantText(allAssistantText) }]);
1364
1517
  }
1365
1518
  else {
1366
- setChatMessages((prev) => [...prev, { role: "assistant", content: assistant }]);
1519
+ setChatMessages((prev) => [...prev, { role: "assistant", content: allAssistantText }]);
1367
1520
  }
1368
1521
  pushMessage("system", "");
1369
1522
  }
1370
1523
  catch (error) {
1371
- pushMessage("error", `Error: ${error instanceof Error ? error.message : String(error)}`);
1524
+ // AbortError fires when user cancels don't show as an error
1525
+ if (error instanceof Error && error.name !== "AbortError") {
1526
+ pushMessage("error", `Error: ${error.message}`);
1527
+ }
1372
1528
  }
1373
1529
  finally {
1530
+ abortRunRef.current = null;
1374
1531
  setThinking(false);
1375
1532
  }
1376
1533
  }, [
@@ -1380,9 +1537,11 @@ export const AstraApp = () => {
1380
1537
  exit,
1381
1538
  handleEvent,
1382
1539
  localFileCache,
1540
+ micSetupDevices,
1383
1541
  openHistory,
1384
1542
  pushMessage,
1385
1543
  sessionId,
1544
+ setMicSetupDevices,
1386
1545
  startLiveVoice,
1387
1546
  stopLiveVoice,
1388
1547
  thinking,
@@ -1394,6 +1553,17 @@ export const AstraApp = () => {
1394
1553
  voiceWaitingForSilence,
1395
1554
  workspaceRoot
1396
1555
  ]);
1556
+ const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
1557
+ useEffect(() => {
1558
+ if (!thinking) {
1559
+ setThinkingColorIdx(0);
1560
+ return;
1561
+ }
1562
+ const interval = setInterval(() => {
1563
+ setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
1564
+ }, 120);
1565
+ return () => clearInterval(interval);
1566
+ }, [thinking]);
1397
1567
  useEffect(() => {
1398
1568
  if (!voiceQueuedPrompt || !user || thinking) {
1399
1569
  return;
@@ -1456,7 +1626,9 @@ export const AstraApp = () => {
1456
1626
  return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { color: active ? "#dce9ff" : "#7a9bba", children: [active ? "❯ " : " ", (row.title || "Untitled").slice(0, 58).padEnd(60, " ")] }), _jsxs(Text, { color: "#5a7a9a", children: [String(row.total_messages ?? 0).padStart(3, " "), " msgs \u00B7 ", formatSessionDate(row.updated_at)] })] }, row.id));
1457
1627
  }) })), _jsx(Text, { color: "#2a3a50", children: divider }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { color: "#5a7a9a", children: ["Page ", historyPage + 1, " / ", historyPageCount] }), _jsxs(Text, { color: "#5a7a9a", children: ["Selected: ", selected ? selected.id : "--"] })] })] }))] }));
1458
1628
  }
1459
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: brand }), _jsx(Text, { color: "#8ea1bd", children: welcomeLine }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#7a9bba", children: `mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${creditsRemaining ?? "--"}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}` }), _jsx(Text, { color: "#6c88a8", children: `scope ${workspaceRoot.length > HEADER_PATH_MAX ? `…${workspaceRoot.slice(-(HEADER_PATH_MAX - 1))}` : workspaceRoot} · voice ${voiceEnabled
1629
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: brand }), _jsx(Text, { color: "#8ea1bd", children: welcomeLine }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#7a9bba", children: `mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${creditsRemaining ?? "--"}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}${loopCtx
1630
+ ? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
1631
+ : ""}` }), _jsx(Text, { color: "#6c88a8", children: `scope ${workspaceRoot.length > HEADER_PATH_MAX ? `…${workspaceRoot.slice(-(HEADER_PATH_MAX - 1))}` : workspaceRoot} · voice ${voiceEnabled
1460
1632
  ? voicePreparing
1461
1633
  ? "on/preparing"
1462
1634
  : voiceListening
@@ -1464,7 +1636,7 @@ export const AstraApp = () => {
1464
1636
  ? "on/waiting"
1465
1637
  : "on/listening"
1466
1638
  : "on/standby"
1467
- : "off"}` }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#3a5068", children: "/help /new /history /voice /dictate on|off|status" }), _jsx(Text, { color: "#3a5068", children: "/tools compact|expanded /settings /logout /exit" }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [messages.map((message, index) => {
1639
+ : "off"}` }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsx(Text, { color: "#3a5068", children: "/help /new /history /voice /dictate on|off|status" }), _jsx(Text, { color: "#3a5068", children: isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit" }), _jsx(Text, { color: "#2a3a50", children: divider }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [messages.map((message, index) => {
1468
1640
  const prev = index > 0 ? messages[index - 1] : null;
1469
1641
  const style = styleForKind(message.kind);
1470
1642
  const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
@@ -1474,11 +1646,21 @@ export const AstraApp = () => {
1474
1646
  message.kind !== "system" &&
1475
1647
  prev?.kind !== message.kind &&
1476
1648
  (message.kind === "assistant" || message.kind === "tool");
1649
+ // Show a subtle turn separator before each assistant response that
1650
+ // follows a tool block — makes it easy to see where one turn ends.
1651
+ const needsTurnSeparator = message.kind === "assistant" &&
1652
+ Boolean(prev) &&
1653
+ prev?.kind === "tool";
1477
1654
  if (isSpacing) {
1478
1655
  return _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: " " }) }, `${index}-spacer`);
1479
1656
  }
1480
1657
  if (message.kind === "tool" && message.card && !isSuperAdmin) {
1481
1658
  const card = message.card;
1659
+ // In compact mode, hide "start" spinner cards — they create noise
1660
+ // (one per tool call) without adding signal after the run completes.
1661
+ if (toolFeedMode === "compact" && card.kind === "start") {
1662
+ return null;
1663
+ }
1482
1664
  const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
1483
1665
  const icon = card.kind === "error"
1484
1666
  ? "✕"
@@ -1506,11 +1688,16 @@ export const AstraApp = () => {
1506
1688
  : card.kind === "preview"
1507
1689
  ? "#9ad5ff"
1508
1690
  : "#9bc5ff";
1509
- return (_jsxs(React.Fragment, { children: [needsGroupGap ? _jsx(Text, { children: " " }) : null, _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, children: paddedLabel }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: accent, children: [icon, " ", card.summary, card.count && card.count > 1 ? _jsxs(Text, { color: "#9cb8d8", children: [" (x", card.count, ")"] }) : null, _jsxs(Text, { color: "#5a7a9a", children: [" \u00B7 ", localityLabel] })] }), toolFeedMode === "expanded" ? (_jsxs(_Fragment, { children: [card.path ? _jsxs(Text, { color: "#6c88a8", children: ["path: ", card.path] }) : null, (card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (_jsx(Text, { color: "#8ea1bd", children: line }, `${index}-snippet-${idx}`)))] })) : null] })] })] }, `${index}-${message.kind}`));
1691
+ // Timestamps fade with age: bright for recent, dim for older
1692
+ const tsAge = message.ts ? Date.now() - message.ts : Infinity;
1693
+ const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
1694
+ return (_jsxs(React.Fragment, { children: [needsTurnSeparator ? _jsx(Text, { color: "#1e2e40", children: "─".repeat(48) }) : needsGroupGap ? _jsx(Text, { children: " " }) : null, _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, children: paddedLabel }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: accent, children: [icon, " ", card.summary, card.path ? _jsxs(Text, { color: "#ffffff", children: [" ", card.path] }) : null, card.count && card.count > 1 ? _jsxs(Text, { color: "#9cb8d8", children: [" (x", card.count, ")"] }) : null, _jsxs(Text, { color: "#5a7a9a", children: [" \u00B7 ", localityLabel] })] }), toolFeedMode === "expanded" ? (_jsxs(_Fragment, { children: [card.path ? _jsxs(Text, { color: "#6c88a8", children: ["path: ", card.path] }) : null, (card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (_jsx(Text, { color: "#8ea1bd", children: line }, `${index}-snippet-${idx}`)))] })) : null] }), message.ts ? _jsxs(Text, { color: tsColor, children: [" ", formatTime(message.ts)] }) : null] })] })] }, `${index}-${message.kind}`));
1510
1695
  }
1511
- return (_jsxs(React.Fragment, { children: [needsGroupGap ? _jsx(Text, { children: " " }) : null, _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, bold: style.bold, children: paddedLabel }), message.kind === "assistant" ? (_jsx(Box, { flexDirection: "column", children: renderMarkdownContent(message.text, style.textColor, `assistant-${index}`) })) : (_jsx(Text, { color: style.textColor, bold: style.bold && message.kind === "error", children: message.text }))] })] }, `${index}-${message.kind}`));
1512
- }), streamingText ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: styleForKind("assistant").label.padEnd(LABEL_WIDTH, " ") }), _jsx(Box, { flexDirection: "column", children: renderMarkdownContent(streamingText, "#dce9ff", "streaming") })] })) : null] }), _jsx(Text, { color: "#2a3a50", children: divider }), thinking ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#7aa2ff", children: "◆ astra".padEnd(LABEL_WIDTH, " ") }), _jsxs(Text, { color: "#6080a0", children: [_jsx(Spinner, { type: "dots2" }), _jsx(Text, { color: "#8aa2c9", children: " thinking..." })] })] })) : null, voiceEnabled && !thinking ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#9ad5ff", children: "🎤 voice".padEnd(LABEL_WIDTH, " ") }), voicePreparing ? (_jsx(Text, { color: "#f4d58a", children: "\uD83D\uDFE1 preparing microphone..." })) : voiceListening && !voiceWaitingForSilence ? (_jsx(Text, { color: "#9de3b4", children: "\uD83D\uDFE2 listening now - speak clearly" })) : voiceWaitingForSilence ? (_jsx(Text, { color: "#b7c4d8", children: "\u23F3 speech detected - waiting for silence to send" })) : (_jsx(Text, { color: "#6f8199", children: "\u26AA voice armed - preparing next listen window" }))] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: "\u276F " }), _jsx(TextInput, { value: prompt, onSubmit: (value) => {
1513
- setPrompt("");
1696
+ const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
1697
+ const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
1698
+ const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
1699
+ return (_jsxs(React.Fragment, { children: [needsTurnSeparator ? _jsx(Text, { color: "#1e2e40", children: "─".repeat(48) }) : needsGroupGap ? _jsx(Text, { children: " " }) : null, _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, bold: style.bold, children: paddedLabel }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [message.kind === "assistant" ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", children: renderMarkdownContent(message.text, style.textColor, `assistant-${index}`) })) : (_jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: style.textColor, bold: style.bold && message.kind === "error", children: message.text }) })), showTs ? _jsxs(Text, { color: tsColor2, children: [" ", formatTime(message.ts)] }) : null] })] })] }, `${index}-${message.kind}`));
1700
+ }), streamingText ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: styleForKind("assistant").label.padEnd(LABEL_WIDTH, " ") }), _jsx(Box, { flexDirection: "column", children: renderMarkdownContent(streamingText, "#dce9ff", "streaming") })] })) : null] }), _jsx(Text, { color: "#2a3a50", children: divider }), thinking && !streamingText ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#7aa2ff", children: "◆ astra".padEnd(LABEL_WIDTH, " ") }), _jsxs(Text, { color: (THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff"), children: [_jsx(Spinner, { type: "dots2" }), _jsx(Text, { color: (THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff"), children: " Working..." }), _jsx(Text, { color: "#2a4060", children: " esc to cancel" })] })] })) : null, voiceEnabled && !thinking ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#9ad5ff", children: "🎤 voice".padEnd(LABEL_WIDTH, " ") }), voicePreparing ? (_jsx(Text, { color: "#f4d58a", children: "\uD83D\uDFE1 preparing microphone..." })) : voiceListening && !voiceWaitingForSilence ? (_jsx(Text, { color: "#9de3b4", children: "\uD83D\uDFE2 listening now - speak clearly" })) : voiceWaitingForSilence ? (_jsx(Text, { color: "#b7c4d8", children: "\u23F3 speech detected - waiting for silence to send" })) : (_jsx(Text, { color: "#6f8199", children: "\u26AA voice armed - preparing next listen window" }))] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: "\u276F " }), _jsx(TextInput, { value: prompt, onSubmit: (value) => {
1514
1701
  void sendPrompt(value);
1515
1702
  }, onChange: (value) => {
1516
1703
  if (!voiceListening) {