@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 +287 -100
- package/dist/app/App.js.map +1 -1
- package/dist/lib/backendClient.js +105 -17
- package/dist/lib/backendClient.js.map +1 -1
- package/dist/lib/voice.js +93 -1
- package/dist/lib/voice.js.map +1 -1
- package/package.json +1 -1
- package/src/app/App.tsx +287 -82
- package/src/lib/backendClient.ts +106 -17
- package/src/lib/voice.ts +97 -1
- package/src/types/events.ts +1 -0
package/src/app/App.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import {Box, Text, useApp, useInput} from "ink";
|
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import {spawn} from "child_process";
|
|
6
|
-
import {mkdirSync, unlinkSync, writeFileSync} from "fs";
|
|
6
|
+
import {existsSync, mkdirSync, unlinkSync, writeFileSync} from "fs";
|
|
7
7
|
import {dirname, join} from "path";
|
|
8
8
|
import {BackendClient, type SessionSummary} from "../lib/backendClient.js";
|
|
9
9
|
import {clearSession, loadSession, saveSession} from "../lib/sessionStore.js";
|
|
@@ -17,7 +17,7 @@ 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 {startLiveTranscription, transcribeOnce, type LiveTranscriptionController} from "../lib/voice.js";
|
|
20
|
+
import {startLiveTranscription, transcribeOnce, resolveAudioDevice, setAudioDevice, listAvfAudioDevices, writeAstraKey, 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
|
|
|
@@ -25,6 +25,7 @@ type UiMessage = {
|
|
|
25
25
|
kind: "system" | "user" | "assistant" | "tool" | "error";
|
|
26
26
|
text: string;
|
|
27
27
|
card?: ToolCard;
|
|
28
|
+
ts?: number;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
type HistoryMode = "picker" | "sessions";
|
|
@@ -198,6 +199,15 @@ const extractAssistantText = (event: AgentEvent): string | null => {
|
|
|
198
199
|
|
|
199
200
|
const LABEL_WIDTH = 10;
|
|
200
201
|
const HEADER_PATH_MAX = Number(process.env.ASTRA_HEADER_PATH_MAX ?? "42");
|
|
202
|
+
|
|
203
|
+
const formatTime = (ts: number): string => {
|
|
204
|
+
const d = new Date(ts);
|
|
205
|
+
const raw = d.getHours();
|
|
206
|
+
const ampm = raw >= 12 ? "pm" : "am";
|
|
207
|
+
const h = raw % 12 === 0 ? 12 : raw % 12;
|
|
208
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
209
|
+
return `${h}:${m} ${ampm}`;
|
|
210
|
+
};
|
|
201
211
|
const MIN_DIVIDER = 64;
|
|
202
212
|
const MAX_DIVIDER = 120;
|
|
203
213
|
|
|
@@ -248,7 +258,13 @@ const normalizeAssistantText = (input: string): string => {
|
|
|
248
258
|
// Remove control chars but preserve newlines/tabs.
|
|
249
259
|
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
|
|
250
260
|
.replace(/([a-z0-9/])([A-Z])/g, "$1 $2")
|
|
251
|
-
|
|
261
|
+
// Add space after sentence-ending punctuation only when followed by an
|
|
262
|
+
// uppercase letter (sentence start). Using [A-Za-z] here would break
|
|
263
|
+
// file extensions like .css, .json, .jsx, .tsx — those always start with
|
|
264
|
+
// a lowercase letter.
|
|
265
|
+
.replace(/([.!?])([A-Z])/g, "$1 $2")
|
|
266
|
+
// For ! and ? followed by lowercase, also add a space (natural English).
|
|
267
|
+
.replace(/([!?])([a-z])/g, "$1 $2")
|
|
252
268
|
.replace(/([a-z])(\u2022)/g, "$1\n$2")
|
|
253
269
|
.replace(/([^\n])(Summary:|Next:|Tests:)/g, "$1\n\n$2")
|
|
254
270
|
.replace(/([A-Za-z0-9_/-]+)\.\s+md\b/g, "$1.md")
|
|
@@ -277,34 +293,54 @@ const normalizeAssistantText = (input: string): string => {
|
|
|
277
293
|
return deduped.join("\n\n").trim();
|
|
278
294
|
};
|
|
279
295
|
|
|
280
|
-
const
|
|
296
|
+
const guessDevUrl = (command: string): string | null => {
|
|
297
|
+
// Extract an explicit --port or -p value from the command.
|
|
298
|
+
const portMatch = command.match(/(?:--port|-p)\s+(\d+)/) ?? command.match(/(?:--port=|-p=)(\d+)/);
|
|
299
|
+
if (portMatch) {
|
|
300
|
+
return `http://localhost:${portMatch[1]}`;
|
|
301
|
+
}
|
|
302
|
+
// Default ports by framework.
|
|
303
|
+
if (/next/.test(command)) return "http://localhost:3000";
|
|
304
|
+
if (/vite|vue/.test(command)) return "http://localhost:5173";
|
|
305
|
+
if (/remix/.test(command)) return "http://localhost:3000";
|
|
306
|
+
if (/astro/.test(command)) return "http://localhost:4321";
|
|
307
|
+
if (/angular|ng\s+serve/.test(command)) return "http://localhost:4200";
|
|
308
|
+
if (/npm\s+run\s+dev|npm\s+start|npx\s+react-scripts/.test(command)) return "http://localhost:3000";
|
|
309
|
+
return null;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const summarizeToolResult = (
|
|
313
|
+
toolName: string,
|
|
314
|
+
data: Record<string, unknown>,
|
|
315
|
+
success: boolean
|
|
316
|
+
): {summary: string; path?: string} => {
|
|
281
317
|
if (!success) {
|
|
282
|
-
return `${toolName} failed
|
|
318
|
+
return {summary: `${toolName} failed`};
|
|
283
319
|
}
|
|
284
320
|
const path = typeof data.path === "string" ? data.path : "";
|
|
285
321
|
const totalLines = typeof data.total_lines === "number" ? data.total_lines : null;
|
|
286
322
|
if (toolName === "view_file") {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
if (path)
|
|
291
|
-
|
|
292
|
-
}
|
|
323
|
+
const result: {summary: string; path?: string} = {
|
|
324
|
+
summary: totalLines !== null ? `Read ${totalLines} lines` : "Read file",
|
|
325
|
+
};
|
|
326
|
+
if (path) result.path = path;
|
|
327
|
+
return result;
|
|
293
328
|
}
|
|
294
329
|
if (toolName === "list_directory") {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
return `Listed <${dir}>`;
|
|
330
|
+
return {
|
|
331
|
+
summary: totalLines !== null ? `Listed ${totalLines} entries` : "Listed directory",
|
|
332
|
+
path: path || ".",
|
|
333
|
+
};
|
|
300
334
|
}
|
|
301
335
|
if (toolName === "semantic_search") {
|
|
302
|
-
return "Searched codebase context";
|
|
336
|
+
return {summary: "Searched codebase context"};
|
|
303
337
|
}
|
|
304
338
|
if (toolName === "search_files") {
|
|
305
|
-
return
|
|
339
|
+
return {
|
|
340
|
+
summary: totalLines !== null ? `Found ${totalLines} matching paths` : "Searched file paths",
|
|
341
|
+
};
|
|
306
342
|
}
|
|
307
|
-
return `${toolName} completed
|
|
343
|
+
return {summary: `${toolName} completed`};
|
|
308
344
|
};
|
|
309
345
|
|
|
310
346
|
const isLikelyVoiceNoise = (text: string): boolean => {
|
|
@@ -602,9 +638,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
602
638
|
const backend = useMemo(() => new BackendClient(), []);
|
|
603
639
|
const {exit} = useApp();
|
|
604
640
|
|
|
641
|
+
// Keep backend token fresh: when the client auto-refreshes on 401, persist and update state.
|
|
642
|
+
useEffect(() => {
|
|
643
|
+
backend.setOnTokenRefreshed((refreshed) => {
|
|
644
|
+
saveSession(refreshed);
|
|
645
|
+
setUser(refreshed);
|
|
646
|
+
});
|
|
647
|
+
}, [backend]);
|
|
648
|
+
|
|
605
649
|
// In-session file cache: tracks files created/edited so subsequent requests
|
|
606
650
|
// include their latest content in workspaceFiles (VirtualFS stays up to date).
|
|
607
651
|
const localFileCache = useRef<Map<string, WorkspaceFile>>(new Map());
|
|
652
|
+
const abortRunRef = useRef<AbortController | null>(null);
|
|
608
653
|
|
|
609
654
|
const writeLocalFile = useCallback(
|
|
610
655
|
(relPath: string, content: string, language: string) => {
|
|
@@ -651,6 +696,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
651
696
|
const [activeModel, setActiveModel] = useState(getDefaultModel());
|
|
652
697
|
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
|
653
698
|
const [lastCreditCost, setLastCreditCost] = useState<number | null>(null);
|
|
699
|
+
const [loopCtx, setLoopCtx] = useState<{in: number; out: number} | null>(null);
|
|
654
700
|
const runtimeMode = getRuntimeMode();
|
|
655
701
|
const [prompt, setPrompt] = useState("");
|
|
656
702
|
const [thinking, setThinking] = useState(false);
|
|
@@ -660,7 +706,9 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
660
706
|
const [voicePreparing, setVoicePreparing] = useState(false);
|
|
661
707
|
const [voiceWaitingForSilence, setVoiceWaitingForSilence] = useState(false);
|
|
662
708
|
const [voiceQueuedPrompt, setVoiceQueuedPrompt] = useState<string | null>(null);
|
|
709
|
+
const [micSetupDevices, setMicSetupDevices] = useState<Array<{index: number; name: string}> | null>(null);
|
|
663
710
|
const [toolFeedMode, setToolFeedMode] = useState<"compact" | "expanded">("compact");
|
|
711
|
+
const [thinkingColorIdx, setThinkingColorIdx] = useState(0);
|
|
664
712
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
665
713
|
const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
|
|
666
714
|
const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
|
|
@@ -676,12 +724,12 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
676
724
|
const isSuperAdmin = user?.role === "super_admin";
|
|
677
725
|
|
|
678
726
|
const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
|
|
679
|
-
setMessages((prev) => [...prev, {kind, text}].slice(-300));
|
|
727
|
+
setMessages((prev) => [...prev, {kind, text, ts: Date.now()}].slice(-300));
|
|
680
728
|
}, []);
|
|
681
729
|
|
|
682
730
|
const pushToolCard = useCallback((card: ToolCard) => {
|
|
683
731
|
setMessages((prev) => {
|
|
684
|
-
const nextEntry = {kind: "tool", text: card.summary, card} satisfies UiMessage;
|
|
732
|
+
const nextEntry = {kind: "tool", text: card.summary, card, ts: Date.now()} satisfies UiMessage;
|
|
685
733
|
const last = prev[prev.length - 1];
|
|
686
734
|
if (
|
|
687
735
|
last &&
|
|
@@ -801,7 +849,29 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
801
849
|
if (announce) {
|
|
802
850
|
pushMessage("system", "Voice input armed. Preparing microphone...");
|
|
803
851
|
}
|
|
804
|
-
|
|
852
|
+
|
|
853
|
+
// Resolve mic device before starting — triggers onboarding if not configured.
|
|
854
|
+
void resolveAudioDevice(workspaceRoot).then((device) => {
|
|
855
|
+
if (device === null) {
|
|
856
|
+
// No device configured — run onboarding inline.
|
|
857
|
+
setVoicePreparing(false);
|
|
858
|
+
const devices = listAvfAudioDevices();
|
|
859
|
+
if (!devices.length) {
|
|
860
|
+
pushMessage("error", "No audio devices found. Install ffmpeg: brew install ffmpeg");
|
|
861
|
+
setVoiceEnabled(false);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
setMicSetupDevices(devices);
|
|
865
|
+
const lines = [
|
|
866
|
+
"Let's set up your microphone first.",
|
|
867
|
+
...devices.map(d => ` [${d.index}] ${d.name}`),
|
|
868
|
+
"Type the number for your mic and press Enter:"
|
|
869
|
+
];
|
|
870
|
+
pushMessage("system", lines.join("\n"));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Device resolved — start transcription.
|
|
874
|
+
liveVoiceRef.current = startLiveTranscription({
|
|
805
875
|
onPartial: (text) => {
|
|
806
876
|
setVoicePreparing(false);
|
|
807
877
|
setVoiceListening(true);
|
|
@@ -857,8 +927,9 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
857
927
|
fetch('http://127.0.0.1:7573/ingest/fdd4f018-1ba3-4303-b1bb-375443267476',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'17f1ea'},body:JSON.stringify({sessionId:'17f1ea',runId:'voice_run_1',hypothesisId:'H4',location:'App.tsx:startLiveVoice.onError',message:'voice transcription error',data:{error:error.message},timestamp:Date.now()})}).catch(()=>{});
|
|
858
928
|
// #endregion
|
|
859
929
|
}
|
|
860
|
-
|
|
861
|
-
|
|
930
|
+
});
|
|
931
|
+
}); // end resolveAudioDevice.then
|
|
932
|
+
}, [pushMessage, stopLiveVoice, workspaceRoot]);
|
|
862
933
|
|
|
863
934
|
useEffect(() => {
|
|
864
935
|
return () => {
|
|
@@ -947,6 +1018,11 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
947
1018
|
if (key.return) {
|
|
948
1019
|
if (trustSelection === 0) {
|
|
949
1020
|
trustWorkspace(workspaceRoot);
|
|
1021
|
+
// Create .astra settings file at workspace root if it doesn't exist yet.
|
|
1022
|
+
try {
|
|
1023
|
+
const astraPath = join(workspaceRoot, ".astra");
|
|
1024
|
+
if (!existsSync(astraPath)) writeFileSync(astraPath, "");
|
|
1025
|
+
} catch { /* non-fatal */ }
|
|
950
1026
|
setTrustedWorkspace(true);
|
|
951
1027
|
setBooting(true);
|
|
952
1028
|
return;
|
|
@@ -962,6 +1038,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
962
1038
|
return;
|
|
963
1039
|
}
|
|
964
1040
|
|
|
1041
|
+
if (key.escape && thinking) {
|
|
1042
|
+
abortRunRef.current?.abort();
|
|
1043
|
+
pushMessage("system", "Cancelled.");
|
|
1044
|
+
pushMessage("system", "");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
965
1048
|
if (historyOpen) {
|
|
966
1049
|
if (key.escape) {
|
|
967
1050
|
if (historyMode === "sessions") {
|
|
@@ -1294,17 +1377,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1294
1377
|
}
|
|
1295
1378
|
|
|
1296
1379
|
if (!isSuperAdmin && event.tool_name === "start_preview") {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1380
|
+
// Server mode returns preview_url (tunnel). Desktop mode returns a
|
|
1381
|
+
// plain message — try to guess the local URL from the command.
|
|
1382
|
+
const tunnelUrl = typeof d.preview_url === "string" ? d.preview_url : null;
|
|
1383
|
+
const command = typeof d.command === "string" ? d.command : "";
|
|
1384
|
+
const localUrl = !tunnelUrl ? guessDevUrl(command) : null;
|
|
1385
|
+
const displayUrl = tunnelUrl ?? localUrl;
|
|
1303
1386
|
pushToolCard({
|
|
1304
1387
|
kind: "preview",
|
|
1305
1388
|
toolName: "start_preview",
|
|
1306
1389
|
locality,
|
|
1307
|
-
summary:
|
|
1390
|
+
summary: "Dev server running",
|
|
1391
|
+
...(displayUrl ? {path: displayUrl} : {}),
|
|
1308
1392
|
});
|
|
1309
1393
|
return null;
|
|
1310
1394
|
}
|
|
@@ -1354,6 +1438,16 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1354
1438
|
}
|
|
1355
1439
|
}
|
|
1356
1440
|
|
|
1441
|
+
if (event.type === "timing") {
|
|
1442
|
+
const ev = event as Record<string, unknown>;
|
|
1443
|
+
if (ev.phase === "llm_done") {
|
|
1444
|
+
const inTok = Number(ev.input_tokens ?? 0);
|
|
1445
|
+
const outTok = Number(ev.output_tokens ?? 0);
|
|
1446
|
+
if (inTok > 0 || outTok > 0) setLoopCtx({in: inTok, out: outTok});
|
|
1447
|
+
}
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1357
1451
|
if (event.type === "continuation_check") {
|
|
1358
1452
|
const recommendation =
|
|
1359
1453
|
typeof event.recommendation === "string" && event.recommendation
|
|
@@ -1397,12 +1491,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1397
1491
|
if (alreadyRepresented) {
|
|
1398
1492
|
return null;
|
|
1399
1493
|
}
|
|
1400
|
-
const summary = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1494
|
+
const {summary, path: summaryPath} = summarizeToolResult(toolName, payload, Boolean(event.success));
|
|
1401
1495
|
pushToolCard({
|
|
1402
1496
|
kind: event.success ? "success" : "error",
|
|
1403
1497
|
toolName,
|
|
1404
1498
|
locality: payload.local === true ? "LOCAL" : "REMOTE",
|
|
1405
|
-
summary: event.success ? summary : `${toolName} ${mark}
|
|
1499
|
+
summary: event.success ? summary : `${toolName} ${mark}`,
|
|
1500
|
+
...(summaryPath ? {path: summaryPath} : {}),
|
|
1406
1501
|
});
|
|
1407
1502
|
}
|
|
1408
1503
|
}
|
|
@@ -1414,7 +1509,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1414
1509
|
}
|
|
1415
1510
|
return null;
|
|
1416
1511
|
},
|
|
1417
|
-
[backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, stopLiveVoice, voiceEnabled, writeLocalFile]
|
|
1512
|
+
[backend, deleteLocalFile, isSuperAdmin, pushMessage, pushToolCard, setLoopCtx, stopLiveVoice, voiceEnabled, writeLocalFile]
|
|
1418
1513
|
);
|
|
1419
1514
|
|
|
1420
1515
|
const sendPrompt = useCallback(
|
|
@@ -1423,22 +1518,57 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1423
1518
|
if (!text || !user || thinking) {
|
|
1424
1519
|
return;
|
|
1425
1520
|
}
|
|
1521
|
+
setPrompt("");
|
|
1522
|
+
|
|
1523
|
+
// Mic onboarding: intercept when waiting for device selection.
|
|
1524
|
+
if (micSetupDevices !== null) {
|
|
1525
|
+
const idx = parseInt(text, 10);
|
|
1526
|
+
const valid = !isNaN(idx) && idx >= 0 && micSetupDevices.some(d => d.index === idx);
|
|
1527
|
+
if (!valid) {
|
|
1528
|
+
pushMessage("error", `Please type one of: ${micSetupDevices.map(d => d.index).join(", ")}`);
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
const device = `:${idx}`;
|
|
1532
|
+
// Write to .astra local cache
|
|
1533
|
+
writeAstraKey(workspaceRoot, "ASTRA_STT_DEVICE", device);
|
|
1534
|
+
// Persist to backend
|
|
1535
|
+
void backend.updateCliSettings({audio_device_index: idx});
|
|
1536
|
+
// Update in-process cache
|
|
1537
|
+
setAudioDevice(device);
|
|
1538
|
+
setMicSetupDevices(null);
|
|
1539
|
+
pushMessage("system", `Mic set to [${idx}] ${micSetupDevices.find(d => d.index === idx)?.name ?? ""}. Starting voice...`);
|
|
1540
|
+
pushMessage("system", "");
|
|
1541
|
+
startLiveVoice(false);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1426
1544
|
|
|
1427
1545
|
if (text === "/help") {
|
|
1428
1546
|
pushMessage(
|
|
1429
1547
|
"system",
|
|
1430
|
-
|
|
1548
|
+
isSuperAdmin
|
|
1549
|
+
? "/new /history /voice /dictate on|off|status /tools compact|expanded /settings /settings model <id> /logout /exit"
|
|
1550
|
+
: "/new /history /voice /dictate on|off|status /settings /settings model <id> /logout /exit"
|
|
1431
1551
|
);
|
|
1432
1552
|
pushMessage("system", "");
|
|
1433
1553
|
return;
|
|
1434
1554
|
}
|
|
1435
1555
|
if (text === "/tools compact") {
|
|
1556
|
+
if (!isSuperAdmin) {
|
|
1557
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1558
|
+
pushMessage("system", "");
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1436
1561
|
setToolFeedMode("compact");
|
|
1437
1562
|
pushMessage("system", "Tool feed set to compact.");
|
|
1438
1563
|
pushMessage("system", "");
|
|
1439
1564
|
return;
|
|
1440
1565
|
}
|
|
1441
1566
|
if (text === "/tools expanded") {
|
|
1567
|
+
if (!isSuperAdmin) {
|
|
1568
|
+
pushMessage("system", "Unknown command. Type /help for available commands.");
|
|
1569
|
+
pushMessage("system", "");
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1442
1572
|
setToolFeedMode("expanded");
|
|
1443
1573
|
pushMessage("system", "Tool feed set to expanded.");
|
|
1444
1574
|
pushMessage("system", "");
|
|
@@ -1554,6 +1684,10 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1554
1684
|
pushMessage("user", text);
|
|
1555
1685
|
setThinking(true);
|
|
1556
1686
|
setStreamingText("");
|
|
1687
|
+
setLoopCtx(null);
|
|
1688
|
+
|
|
1689
|
+
const abortController = new AbortController();
|
|
1690
|
+
abortRunRef.current = abortController;
|
|
1557
1691
|
|
|
1558
1692
|
try {
|
|
1559
1693
|
// Scan the local workspace so the backend VirtualFS is populated.
|
|
@@ -1564,8 +1698,28 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1564
1698
|
const seenPaths = new Set(sessionFiles.map((f) => f.path));
|
|
1565
1699
|
const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
|
|
1566
1700
|
|
|
1567
|
-
|
|
1701
|
+
// `pendingText` is text received since the last committed block.
|
|
1702
|
+
// It gets flushed to the messages list whenever tool activity starts,
|
|
1703
|
+
// keeping text and tool cards in the exact order they were emitted.
|
|
1704
|
+
let pendingText = "";
|
|
1705
|
+
let allAssistantText = "";
|
|
1568
1706
|
let localActionConfirmed = false;
|
|
1707
|
+
|
|
1708
|
+
const commitPending = (applyGuard = false) => {
|
|
1709
|
+
if (!pendingText.trim()) {
|
|
1710
|
+
pendingText = "";
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const clean = normalizeAssistantText(pendingText);
|
|
1714
|
+
const msg =
|
|
1715
|
+
applyGuard && !localActionConfirmed && looksLikeLocalFilesystemClaim(clean)
|
|
1716
|
+
? `Remote result (not yet confirmed as local filesystem change): ${clean}`
|
|
1717
|
+
: clean;
|
|
1718
|
+
pushMessage("assistant", msg);
|
|
1719
|
+
pendingText = "";
|
|
1720
|
+
setStreamingText("");
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1569
1723
|
for await (const event of backend.streamChat({
|
|
1570
1724
|
user,
|
|
1571
1725
|
sessionId: activeSessionId,
|
|
@@ -1573,7 +1727,8 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1573
1727
|
workspaceRoot,
|
|
1574
1728
|
workspaceTree,
|
|
1575
1729
|
workspaceFiles: mergedFiles,
|
|
1576
|
-
model: activeModel
|
|
1730
|
+
model: activeModel,
|
|
1731
|
+
signal: abortController.signal
|
|
1577
1732
|
})) {
|
|
1578
1733
|
if (event.type === "run_in_terminal") {
|
|
1579
1734
|
localActionConfirmed = true;
|
|
@@ -1587,29 +1742,35 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1587
1742
|
if (event.type === "done") {
|
|
1588
1743
|
break;
|
|
1589
1744
|
}
|
|
1745
|
+
// Flush any accumulated text before the first tool event so that text
|
|
1746
|
+
// appears above the tool cards that follow it — preserving order.
|
|
1747
|
+
if (event.type === "tool_start" || event.type === "run_in_terminal") {
|
|
1748
|
+
commitPending();
|
|
1749
|
+
}
|
|
1590
1750
|
const piece = await handleEvent(event, activeSessionId);
|
|
1591
1751
|
if (piece) {
|
|
1592
|
-
|
|
1593
|
-
|
|
1752
|
+
pendingText += piece;
|
|
1753
|
+
allAssistantText += piece;
|
|
1754
|
+
setStreamingText(normalizeAssistantText(pendingText));
|
|
1594
1755
|
}
|
|
1595
1756
|
}
|
|
1596
1757
|
|
|
1597
1758
|
setStreamingText("");
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
|
|
1603
|
-
: cleanedAssistant;
|
|
1604
|
-
pushMessage("assistant", guardedAssistant);
|
|
1605
|
-
setChatMessages((prev) => [...prev, {role: "assistant", content: cleanedAssistant}]);
|
|
1759
|
+
commitPending(true);
|
|
1760
|
+
// Update conversation history for the backend with the full combined text.
|
|
1761
|
+
if (allAssistantText.trim()) {
|
|
1762
|
+
setChatMessages((prev) => [...prev, {role: "assistant", content: normalizeAssistantText(allAssistantText)}]);
|
|
1606
1763
|
} else {
|
|
1607
|
-
setChatMessages((prev) => [...prev, {role: "assistant", content:
|
|
1764
|
+
setChatMessages((prev) => [...prev, {role: "assistant", content: allAssistantText}]);
|
|
1608
1765
|
}
|
|
1609
1766
|
pushMessage("system", "");
|
|
1610
1767
|
} catch (error) {
|
|
1611
|
-
|
|
1768
|
+
// AbortError fires when user cancels — don't show as an error
|
|
1769
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
1770
|
+
pushMessage("error", `Error: ${error.message}`);
|
|
1771
|
+
}
|
|
1612
1772
|
} finally {
|
|
1773
|
+
abortRunRef.current = null;
|
|
1613
1774
|
setThinking(false);
|
|
1614
1775
|
}
|
|
1615
1776
|
},
|
|
@@ -1620,9 +1781,11 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1620
1781
|
exit,
|
|
1621
1782
|
handleEvent,
|
|
1622
1783
|
localFileCache,
|
|
1784
|
+
micSetupDevices,
|
|
1623
1785
|
openHistory,
|
|
1624
1786
|
pushMessage,
|
|
1625
1787
|
sessionId,
|
|
1788
|
+
setMicSetupDevices,
|
|
1626
1789
|
startLiveVoice,
|
|
1627
1790
|
stopLiveVoice,
|
|
1628
1791
|
thinking,
|
|
@@ -1636,6 +1799,18 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1636
1799
|
]
|
|
1637
1800
|
);
|
|
1638
1801
|
|
|
1802
|
+
const THINKING_COLORS = ["#6a94f5", "#7aa2ff", "#88b4ff", "#99c4ff", "#aad0ff", "#99c4ff", "#88b4ff", "#7aa2ff"];
|
|
1803
|
+
useEffect(() => {
|
|
1804
|
+
if (!thinking) {
|
|
1805
|
+
setThinkingColorIdx(0);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
const interval = setInterval(() => {
|
|
1809
|
+
setThinkingColorIdx((prev) => (prev + 1) % THINKING_COLORS.length);
|
|
1810
|
+
}, 120);
|
|
1811
|
+
return () => clearInterval(interval);
|
|
1812
|
+
}, [thinking]);
|
|
1813
|
+
|
|
1639
1814
|
useEffect(() => {
|
|
1640
1815
|
if (!voiceQueuedPrompt || !user || thinking) {
|
|
1641
1816
|
return;
|
|
@@ -1867,7 +2042,11 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1867
2042
|
<Text color="#7a9bba">
|
|
1868
2043
|
{`mode ${runtimeMode} · provider ${getProviderForModel(activeModel)} · model ${activeModel} · credits ${
|
|
1869
2044
|
creditsRemaining ?? "--"
|
|
1870
|
-
}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}
|
|
2045
|
+
}${lastCreditCost !== null ? ` (-${lastCreditCost})` : ""}${
|
|
2046
|
+
loopCtx
|
|
2047
|
+
? ` · context: ${loopCtx.in >= 1000 ? `${(loopCtx.in / 1000).toFixed(1)}K` : loopCtx.in} / ${loopCtx.out >= 1000 ? `${(loopCtx.out / 1000).toFixed(1)}K` : loopCtx.out}`
|
|
2048
|
+
: ""
|
|
2049
|
+
}`}
|
|
1871
2050
|
</Text>
|
|
1872
2051
|
<Text color="#6c88a8">
|
|
1873
2052
|
{`scope ${
|
|
@@ -1886,7 +2065,7 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1886
2065
|
</Text>
|
|
1887
2066
|
<Text color="#2a3a50">{divider}</Text>
|
|
1888
2067
|
<Text color="#3a5068">/help /new /history /voice /dictate on|off|status</Text>
|
|
1889
|
-
<Text color="#3a5068"
|
|
2068
|
+
<Text color="#3a5068">{isSuperAdmin ? "/tools compact|expanded /settings /logout /exit" : "/settings /logout /exit"}</Text>
|
|
1890
2069
|
<Text color="#2a3a50">{divider}</Text>
|
|
1891
2070
|
<Box flexDirection="column" marginTop={1}>
|
|
1892
2071
|
{messages.map((message, index) => {
|
|
@@ -1900,12 +2079,23 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1900
2079
|
message.kind !== "system" &&
|
|
1901
2080
|
prev?.kind !== message.kind &&
|
|
1902
2081
|
(message.kind === "assistant" || message.kind === "tool");
|
|
2082
|
+
// Show a subtle turn separator before each assistant response that
|
|
2083
|
+
// follows a tool block — makes it easy to see where one turn ends.
|
|
2084
|
+
const needsTurnSeparator =
|
|
2085
|
+
message.kind === "assistant" &&
|
|
2086
|
+
Boolean(prev) &&
|
|
2087
|
+
prev?.kind === "tool";
|
|
1903
2088
|
if (isSpacing) {
|
|
1904
2089
|
return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
|
|
1905
2090
|
}
|
|
1906
2091
|
|
|
1907
2092
|
if (message.kind === "tool" && message.card && !isSuperAdmin) {
|
|
1908
2093
|
const card = message.card;
|
|
2094
|
+
// In compact mode, hide "start" spinner cards — they create noise
|
|
2095
|
+
// (one per tool call) without adding signal after the run completes.
|
|
2096
|
+
if (toolFeedMode === "compact" && card.kind === "start") {
|
|
2097
|
+
return null;
|
|
2098
|
+
}
|
|
1909
2099
|
const localityLabel = card.locality === "LOCAL" ? "local" : "cloud";
|
|
1910
2100
|
const icon =
|
|
1911
2101
|
card.kind === "error"
|
|
@@ -1935,49 +2125,64 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1935
2125
|
: card.kind === "preview"
|
|
1936
2126
|
? "#9ad5ff"
|
|
1937
2127
|
: "#9bc5ff";
|
|
2128
|
+
// Timestamps fade with age: bright for recent, dim for older
|
|
2129
|
+
const tsAge = message.ts ? Date.now() - message.ts : Infinity;
|
|
2130
|
+
const tsColor = tsAge < 120000 ? "#68d5c8" : tsAge < 600000 ? "#3d8a82" : "#2a5a55";
|
|
1938
2131
|
return (
|
|
1939
2132
|
<React.Fragment key={`${index}-${message.kind}`}>
|
|
1940
|
-
{needsGroupGap ? <Text> </Text> : null}
|
|
2133
|
+
{needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
|
|
1941
2134
|
<Box flexDirection="row">
|
|
1942
2135
|
<Text color={style.labelColor}>{paddedLabel}</Text>
|
|
1943
|
-
<Box flexDirection="
|
|
1944
|
-
<
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
<Text
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2136
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
2137
|
+
<Box flexGrow={1} flexDirection="column">
|
|
2138
|
+
<Text color={accent}>
|
|
2139
|
+
{icon} {card.summary}
|
|
2140
|
+
{card.path ? <Text color="#ffffff"> {card.path}</Text> : null}
|
|
2141
|
+
{card.count && card.count > 1 ? <Text color="#9cb8d8"> (x{card.count})</Text> : null}
|
|
2142
|
+
<Text color="#5a7a9a"> · {localityLabel}</Text>
|
|
2143
|
+
</Text>
|
|
2144
|
+
{toolFeedMode === "expanded" ? (
|
|
2145
|
+
<>
|
|
2146
|
+
{card.path ? <Text color="#6c88a8">path: {card.path}</Text> : null}
|
|
2147
|
+
{(card.snippetLines ?? []).slice(0, TOOL_SNIPPET_LINES).map((line, idx) => (
|
|
2148
|
+
<Text key={`${index}-snippet-${idx}`} color="#8ea1bd">
|
|
2149
|
+
{line}
|
|
2150
|
+
</Text>
|
|
2151
|
+
))}
|
|
2152
|
+
</>
|
|
2153
|
+
) : null}
|
|
2154
|
+
</Box>
|
|
2155
|
+
{message.ts ? <Text color={tsColor}> {formatTime(message.ts)}</Text> : null}
|
|
1959
2156
|
</Box>
|
|
1960
2157
|
</Box>
|
|
1961
2158
|
</React.Fragment>
|
|
1962
2159
|
);
|
|
1963
2160
|
}
|
|
1964
2161
|
|
|
2162
|
+
const showTs = Boolean(message.ts) && (message.kind === "user" || message.kind === "assistant");
|
|
2163
|
+
const tsAge2 = message.ts ? Date.now() - message.ts : Infinity;
|
|
2164
|
+
const tsColor2 = tsAge2 < 120000 ? "#68d5c8" : tsAge2 < 600000 ? "#3d8a82" : "#2a5a55";
|
|
1965
2165
|
return (
|
|
1966
2166
|
<React.Fragment key={`${index}-${message.kind}`}>
|
|
1967
|
-
{needsGroupGap ? <Text> </Text> : null}
|
|
2167
|
+
{needsTurnSeparator ? <Text color="#1e2e40">{"─".repeat(48)}</Text> : needsGroupGap ? <Text> </Text> : null}
|
|
1968
2168
|
<Box flexDirection="row">
|
|
1969
2169
|
<Text color={style.labelColor} bold={style.bold}>
|
|
1970
2170
|
{paddedLabel}
|
|
1971
2171
|
</Text>
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
{
|
|
1979
|
-
|
|
1980
|
-
|
|
2172
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
2173
|
+
{message.kind === "assistant" ? (
|
|
2174
|
+
<Box flexGrow={1} flexDirection="column">
|
|
2175
|
+
{renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
|
|
2176
|
+
</Box>
|
|
2177
|
+
) : (
|
|
2178
|
+
<Box flexGrow={1}>
|
|
2179
|
+
<Text color={style.textColor} bold={style.bold && message.kind === "error"}>
|
|
2180
|
+
{message.text}
|
|
2181
|
+
</Text>
|
|
2182
|
+
</Box>
|
|
2183
|
+
)}
|
|
2184
|
+
{showTs ? <Text color={tsColor2}> {formatTime(message.ts as number)}</Text> : null}
|
|
2185
|
+
</Box>
|
|
1981
2186
|
</Box>
|
|
1982
2187
|
</React.Fragment>
|
|
1983
2188
|
);
|
|
@@ -1990,12 +2195,13 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
1990
2195
|
) : null}
|
|
1991
2196
|
</Box>
|
|
1992
2197
|
<Text color="#2a3a50">{divider}</Text>
|
|
1993
|
-
{thinking ? (
|
|
2198
|
+
{thinking && !streamingText ? (
|
|
1994
2199
|
<Box flexDirection="row" marginTop={1}>
|
|
1995
2200
|
<Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
|
|
1996
|
-
<Text color="#
|
|
2201
|
+
<Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}>
|
|
1997
2202
|
<Spinner type="dots2" />
|
|
1998
|
-
<Text color="#
|
|
2203
|
+
<Text color={(THINKING_COLORS[thinkingColorIdx] ?? "#7aa2ff") as string}> Working...</Text>
|
|
2204
|
+
<Text color="#2a4060"> esc to cancel</Text>
|
|
1999
2205
|
</Text>
|
|
2000
2206
|
</Box>
|
|
2001
2207
|
) : null}
|
|
@@ -2018,7 +2224,6 @@ export const AstraApp = (): React.JSX.Element => {
|
|
|
2018
2224
|
<TextInput
|
|
2019
2225
|
value={prompt}
|
|
2020
2226
|
onSubmit={(value) => {
|
|
2021
|
-
setPrompt("");
|
|
2022
2227
|
void sendPrompt(value);
|
|
2023
2228
|
}}
|
|
2024
2229
|
onChange={(value) => {
|