@astra-code/astra-ai 0.1.0

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.
@@ -0,0 +1,1429 @@
1
+ import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
2
+ import {Box, Text, useApp, useInput} from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import TextInput from "ink-text-input";
5
+ import {spawn} from "child_process";
6
+ import {mkdirSync, unlinkSync, writeFileSync} from "fs";
7
+ import {dirname, join} from "path";
8
+ import {BackendClient, type SessionSummary} from "../lib/backendClient.js";
9
+ import {clearSession, loadSession, saveSession} from "../lib/sessionStore.js";
10
+ import {
11
+ getBackendUrl,
12
+ getDefaultClientId,
13
+ getDefaultModel,
14
+ getProviderForModel,
15
+ getRuntimeMode
16
+ } from "../lib/config.js";
17
+ import {runTerminalCommand} from "../lib/terminalBridge.js";
18
+ import {isWorkspaceTrusted, trustWorkspace} from "../lib/trustStore.js";
19
+ import {scanWorkspace} from "../lib/workspaceScanner.js";
20
+ import {speakText, startLiveTranscription, transcribeOnce, type LiveTranscriptionController} from "../lib/voice.js";
21
+ import type {AgentEvent, AuthSession, ChatMessage} from "../types/events.js";
22
+ import type {WorkspaceFile} from "../lib/workspaceScanner.js";
23
+
24
+ type UiMessage = {
25
+ kind: "system" | "user" | "assistant" | "tool" | "error";
26
+ text: string;
27
+ };
28
+
29
+ type HistoryMode = "picker" | "sessions";
30
+
31
+ // const ASTRA_ASCII = `
32
+ // █████╗ ███████╗████████╗██████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗
33
+ // ██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
34
+ // ███████║███████╗ ██║ ██████╔╝███████║ ██║ ██║ ██║██║ ██║█████╗
35
+ // ██╔══██║╚════██║ ██║ ██╔══██╗██╔══██║ ██║ ██║ ██║██║ ██║██╔══╝
36
+ // ██║ ██║███████║ ██║ ██║ ██║██║ ██║ ╚██████╗╚██████╔╝██████╔╝███████╗
37
+ // ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
38
+ // by Sean Donovan
39
+ // `;
40
+
41
+ const ASTRA_ASCII = `
42
+ ::: :::::::: ::::::::::: ::::::::: ::: :::::::: :::::::: ::::::::: ::::::::::
43
+ :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼:
44
+ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼
45
+ ┼#┼┼:┼┼#┼┼: ┼#┼┼:┼┼#┼┼ ┼#┼ ┼#┼┼:┼┼#: ┼#┼┼:┼┼#┼┼: ┼#┼ ┼#┼ ┼:┼ ┼#┼ ┼:┼ ┼#┼┼:┼┼#
46
+ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼
47
+ #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼#
48
+ ### ### ######## ### ### ### ### ### ######## ######## ######### ##########
49
+ by Sean Donovan
50
+ `;
51
+
52
+ const WELCOME_WIDTH = 96;
53
+
54
+ const centerLine = (text: string, width = WELCOME_WIDTH): string => {
55
+ const trimmed = text.trim();
56
+ if (trimmed.length >= width) {
57
+ return trimmed;
58
+ }
59
+ const left = Math.floor((width - trimmed.length) / 2);
60
+ return `${" ".repeat(left)}${trimmed}`;
61
+ };
62
+
63
+ const FOUNDER_WELCOME = centerLine("Welcome to Astra from Astra CEO & Founder, Sean Donovan");
64
+
65
+ const HISTORY_SETTINGS_URL = "https://astra-web-builder.vercel.app/settings";
66
+
67
+ const eventToToolLine = (event: AgentEvent): string | null => {
68
+ if (event.type === "tool_start") {
69
+ const name = event.tool?.name ?? "tool";
70
+ return `↳ ${name} executing...`;
71
+ }
72
+ if (event.type === "tool_result") {
73
+ const success = Boolean(event.success);
74
+ const mark = success ? "✓" : "✗";
75
+ const toolName = event.tool_name ?? "tool";
76
+ const payload = (event.data ?? {}) as Record<string, unknown>;
77
+ const output = String(
78
+ (payload.output as string | undefined) ??
79
+ (payload.content as string | undefined) ??
80
+ event.error ??
81
+ ""
82
+ );
83
+ const locality = payload.local === true ? "LOCAL" : "REMOTE";
84
+ return `[${locality}] ${mark} ${toolName} ${output.slice(0, 240)}`;
85
+ }
86
+ if (event.type === "credits_exhausted") {
87
+ return typeof event.message === "string" ? event.message : "Credits exhausted.";
88
+ }
89
+ if (event.type === "error") {
90
+ const error = typeof event.error === "string" ? event.error : "";
91
+ const content = typeof event.content === "string" ? event.content : "";
92
+ return error || content || "Unknown error";
93
+ }
94
+ return null;
95
+ };
96
+
97
+ const looksLikeLocalFilesystemClaim = (text: string): boolean => {
98
+ const lower = text.toLowerCase();
99
+ const changeWord =
100
+ lower.includes("created") ||
101
+ lower.includes("updated") ||
102
+ lower.includes("deleted") ||
103
+ lower.includes("wrote") ||
104
+ lower.includes("saved");
105
+ const fsWord =
106
+ lower.includes("file") ||
107
+ lower.includes("directory") ||
108
+ lower.includes("folder") ||
109
+ lower.includes("workspace");
110
+ return changeWord && fsWord;
111
+ };
112
+
113
+ const extractAssistantText = (event: AgentEvent): string | null => {
114
+ if (event.type === "text") {
115
+ return typeof event.content === "string" ? event.content : "";
116
+ }
117
+
118
+ const asRecord = event as Record<string, unknown>;
119
+ const directContent = asRecord.content;
120
+ if (typeof directContent === "string" && directContent) {
121
+ return directContent;
122
+ }
123
+
124
+ const delta = asRecord.delta;
125
+ if (typeof delta === "string" && delta) {
126
+ return delta;
127
+ }
128
+
129
+ const text = asRecord.text;
130
+ if (typeof text === "string" && text) {
131
+ return text;
132
+ }
133
+
134
+ const data = asRecord.data;
135
+ if (data && typeof data === "object") {
136
+ const dataRecord = data as Record<string, unknown>;
137
+ const nestedText = dataRecord.text ?? dataRecord.content ?? dataRecord.delta;
138
+ if (typeof nestedText === "string" && nestedText) {
139
+ return nestedText;
140
+ }
141
+ }
142
+
143
+ return null;
144
+ };
145
+
146
+ const DIVIDER =
147
+ "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
148
+
149
+ const LABEL_WIDTH = 10;
150
+
151
+ type MessageStyle = {
152
+ label: string;
153
+ labelColor: string;
154
+ textColor: string;
155
+ bold: boolean;
156
+ };
157
+
158
+ const styleForKind = (kind: UiMessage["kind"]): MessageStyle => {
159
+ switch (kind) {
160
+ case "assistant":
161
+ return {label: "◆ astra", labelColor: "#7aa2ff", textColor: "#dce9ff", bold: false};
162
+ case "user":
163
+ return {label: "▸ you", labelColor: "#9ad5ff", textColor: "#c8e0ff", bold: false};
164
+ case "tool":
165
+ return {label: "⚙ tool", labelColor: "#5a7a9a", textColor: "#7a9bba", bold: false};
166
+ case "error":
167
+ return {label: "✦ error", labelColor: "#ff6b6b", textColor: "#ffaaaa", bold: true};
168
+ default:
169
+ return {label: "· note", labelColor: "#506070", textColor: "#8ea1bd", bold: false};
170
+ }
171
+ };
172
+
173
+ const formatSessionDate = (dateStr: string): string => {
174
+ if (!dateStr) {
175
+ return "unknown";
176
+ }
177
+ const d = new Date(dateStr);
178
+ if (Number.isNaN(d.getTime())) {
179
+ return dateStr;
180
+ }
181
+ const now = new Date();
182
+ const days = Math.floor((now.getTime() - d.getTime()) / 86400000);
183
+ if (days <= 0) return "Today";
184
+ if (days === 1) return "Yesterday";
185
+ if (days < 7) return `${days}d ago`;
186
+ return d.toLocaleDateString();
187
+ };
188
+
189
+ const normalizeAssistantText = (input: string): string => {
190
+ if (!input) {
191
+ return "";
192
+ }
193
+
194
+ return input
195
+ // Remove control chars but preserve newlines/tabs.
196
+ .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
197
+ // Trim trailing spaces line-by-line.
198
+ .replace(/[ \t]+$/gm, "")
199
+ // Normalize excessive blank lines.
200
+ .replace(/\n{3,}/g, "\n\n")
201
+ .trim();
202
+ };
203
+
204
+ type InlineToken = {text: string; bold?: boolean; italic?: boolean; code?: boolean};
205
+
206
+ const parseInline = (line: string): InlineToken[] => {
207
+ const tokens: InlineToken[] = [];
208
+ const re = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_)/g;
209
+ let last = 0;
210
+ let m: RegExpExecArray | null;
211
+ while ((m = re.exec(line)) !== null) {
212
+ if (m.index > last) {
213
+ tokens.push({text: line.slice(last, m.index)});
214
+ }
215
+ const seg = m[0];
216
+ if (seg.startsWith("`")) {
217
+ tokens.push({text: seg.slice(1, -1), code: true});
218
+ } else if (seg.startsWith("**") || seg.startsWith("__")) {
219
+ tokens.push({text: seg.slice(2, -2), bold: true});
220
+ } else {
221
+ tokens.push({text: seg.slice(1, -1), italic: true});
222
+ }
223
+ last = re.lastIndex;
224
+ }
225
+ if (last < line.length) {
226
+ tokens.push({text: line.slice(last)});
227
+ }
228
+ return tokens;
229
+ };
230
+
231
+ const parseTableRow = (row: string): string[] => {
232
+ const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
233
+ return trimmed.split("|").map((cell) => cell.trim());
234
+ };
235
+
236
+ const wrapTextToWidth = (text: string, width: number): string[] => {
237
+ const normalized = text.replace(/\s+/g, " ").trim();
238
+ if (!normalized) {
239
+ return [""];
240
+ }
241
+ if (normalized.length <= width) {
242
+ return [normalized];
243
+ }
244
+
245
+ const words = normalized.split(" ");
246
+ const lines: string[] = [];
247
+ let current = "";
248
+
249
+ for (const word of words) {
250
+ if (!word) {
251
+ continue;
252
+ }
253
+ const candidate = current ? `${current} ${word}` : word;
254
+ if (candidate.length <= width) {
255
+ current = candidate;
256
+ continue;
257
+ }
258
+
259
+ if (current) {
260
+ lines.push(current);
261
+ current = "";
262
+ }
263
+
264
+ if (word.length <= width) {
265
+ current = word;
266
+ continue;
267
+ }
268
+
269
+ // Hard-wrap long tokens (e.g. URLs) that exceed column width.
270
+ for (let i = 0; i < word.length; i += width) {
271
+ lines.push(word.slice(i, i + width));
272
+ }
273
+ }
274
+
275
+ if (current) {
276
+ lines.push(current);
277
+ }
278
+ return lines.length > 0 ? lines : [""];
279
+ };
280
+
281
+ const renderMarkdownContent = (text: string, baseColor: string, keyPrefix: string): React.JSX.Element[] => {
282
+ const lines = text.split("\n");
283
+ const out: React.JSX.Element[] = [];
284
+ let i = 0;
285
+ let inCode = false;
286
+
287
+ while (i < lines.length) {
288
+ const line = lines[i] ?? "";
289
+ const trimmed = line.trim();
290
+
291
+ if (trimmed.startsWith("```")) {
292
+ inCode = !inCode;
293
+ i += 1;
294
+ continue;
295
+ }
296
+
297
+ if (inCode) {
298
+ out.push(
299
+ <Text key={`${keyPrefix}-code-${i}`} color="#9ad5ff">
300
+ {line}
301
+ </Text>
302
+ );
303
+ i += 1;
304
+ continue;
305
+ }
306
+
307
+ const next = lines[i + 1] ?? "";
308
+ const tableSep = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(next);
309
+ if (line.includes("|") && tableSep) {
310
+ const rows: string[][] = [];
311
+ rows.push(parseTableRow(line));
312
+ i += 2; // skip header + separator
313
+ while (i < lines.length) {
314
+ const current = lines[i] ?? "";
315
+ if (!current.includes("|")) {
316
+ break;
317
+ }
318
+ rows.push(parseTableRow(current));
319
+ i += 1;
320
+ }
321
+ const colCount = Math.max(...rows.map((r) => r.length));
322
+ const widths = new Array(colCount).fill(0).map((_, col) => {
323
+ const longest = Math.max(...rows.map((r) => (r[col] ?? "").length));
324
+ return Math.max(8, Math.min(24, longest));
325
+ });
326
+
327
+ const renderTableRow = (cells: string[], key: string, color: string, bold = false): void => {
328
+ const wrapped = widths.map((w, idx) => wrapTextToWidth(cells[idx] ?? "", w));
329
+ const height = Math.max(...wrapped.map((parts) => parts.length));
330
+ for (let rowLine = 0; rowLine < height; rowLine += 1) {
331
+ out.push(
332
+ <Text key={`${key}-${rowLine}`} color={color} bold={bold}>
333
+ {`| ${widths
334
+ .map((w, idx) => (wrapped[idx]?.[rowLine] ?? "").padEnd(w, " "))
335
+ .join(" | ")} |`}
336
+ </Text>
337
+ );
338
+ }
339
+ };
340
+
341
+ const headerRow = rows[0] ?? [];
342
+ renderTableRow(headerRow, `${keyPrefix}-table-head-${i}`, "#c8e0ff", true);
343
+ out.push(
344
+ <Text key={`${keyPrefix}-table-sep-${i}`} color="#5a7a9a">
345
+ {`|-${widths.map((w) => "".padEnd(w, "-")).join("-|-")}-|`}
346
+ </Text>
347
+ );
348
+ rows.slice(1).forEach((r, idx) => {
349
+ renderTableRow(r, `${keyPrefix}-table-row-${i}-${idx}`, baseColor);
350
+ });
351
+ continue;
352
+ }
353
+
354
+ let work = line;
355
+
356
+ if (/^\s*>\s?/.test(work)) {
357
+ const quoteText = work.replace(/^\s*>\s?/, "");
358
+ const quoteTokens = parseInline(quoteText);
359
+ out.push(
360
+ <Box key={`${keyPrefix}-quote-${i}`} flexDirection="row">
361
+ <Text color="#5a7a9a">▎ </Text>
362
+ <Text color="#a8bad3" italic>
363
+ {quoteTokens.map((t, idx) => (
364
+ <Text
365
+ key={`${keyPrefix}-quote-${i}-tok-${idx}`}
366
+ bold={Boolean(t.bold)}
367
+ italic
368
+ color={t.code ? "#9ad5ff" : "#a8bad3"}
369
+ >
370
+ {t.text}
371
+ </Text>
372
+ ))}
373
+ </Text>
374
+ </Box>
375
+ );
376
+ i += 1;
377
+ continue;
378
+ }
379
+
380
+ const numberedMatch = work.match(/^(\s*)(\d+\.)\s+(.*)$/);
381
+ if (numberedMatch) {
382
+ const [, indent, marker, body] = numberedMatch;
383
+ const tokens = parseInline(body ?? "");
384
+ out.push(
385
+ <Text key={`${keyPrefix}-ol-${i}`} color={baseColor}>
386
+ {indent}
387
+ <Text color="#9ad5ff" bold>
388
+ {marker}
389
+ </Text>
390
+ {" "}
391
+ {tokens.map((t, idx) => (
392
+ <Text
393
+ key={`${keyPrefix}-ol-${i}-tok-${idx}`}
394
+ bold={Boolean(t.bold)}
395
+ italic={Boolean(t.italic)}
396
+ color={t.code ? "#9ad5ff" : baseColor}
397
+ >
398
+ {t.text}
399
+ </Text>
400
+ ))}
401
+ </Text>
402
+ );
403
+ i += 1;
404
+ continue;
405
+ }
406
+
407
+ if (/^\s*[-*]\s+/.test(work)) {
408
+ const bulletBody = work.replace(/^\s*[-*]\s+/, "");
409
+ const tokens = parseInline(bulletBody);
410
+ out.push(
411
+ <Text key={`${keyPrefix}-ul-${i}`} color={baseColor}>
412
+ <Text color="#9ad5ff">• </Text>
413
+ {tokens.map((t, idx) => (
414
+ <Text
415
+ key={`${keyPrefix}-ul-${i}-tok-${idx}`}
416
+ bold={Boolean(t.bold)}
417
+ italic={Boolean(t.italic)}
418
+ color={t.code ? "#9ad5ff" : baseColor}
419
+ >
420
+ {t.text}
421
+ </Text>
422
+ ))}
423
+ </Text>
424
+ );
425
+ i += 1;
426
+ continue;
427
+ }
428
+
429
+ if (/^\s{0,3}#{1,6}\s+/.test(work)) {
430
+ work = work.replace(/^\s{0,3}#{1,6}\s+/, "");
431
+ out.push(
432
+ <Text key={`${keyPrefix}-h-${i}`} color="#dce9ff" bold>
433
+ {work}
434
+ </Text>
435
+ );
436
+ i += 1;
437
+ continue;
438
+ }
439
+
440
+ const tokens = parseInline(work);
441
+ out.push(
442
+ <Text key={`${keyPrefix}-line-${i}`} color={baseColor}>
443
+ {tokens.map((t, idx) => (
444
+ <Text
445
+ key={`${keyPrefix}-line-${i}-tok-${idx}`}
446
+ bold={Boolean(t.bold)}
447
+ italic={Boolean(t.italic)}
448
+ color={t.code ? "#9ad5ff" : baseColor}
449
+ >
450
+ {t.text}
451
+ </Text>
452
+ ))}
453
+ </Text>
454
+ );
455
+ i += 1;
456
+ }
457
+
458
+ return out;
459
+ };
460
+
461
+ export const AstraApp = (): React.JSX.Element => {
462
+ const workspaceRoot = useMemo(() => process.cwd(), []);
463
+ const backend = useMemo(() => new BackendClient(), []);
464
+ const {exit} = useApp();
465
+
466
+ // In-session file cache: tracks files created/edited so subsequent requests
467
+ // include their latest content in workspaceFiles (VirtualFS stays up to date).
468
+ const localFileCache = useRef<Map<string, WorkspaceFile>>(new Map());
469
+
470
+ const writeLocalFile = useCallback(
471
+ (relPath: string, content: string, language: string) => {
472
+ try {
473
+ const abs = join(workspaceRoot, relPath);
474
+ mkdirSync(dirname(abs), {recursive: true});
475
+ writeFileSync(abs, content, "utf-8");
476
+ localFileCache.current.set(relPath, {path: relPath, content, language});
477
+ } catch (e) {
478
+ // Best-effort — App.tsx callers handle the error message separately.
479
+ }
480
+ },
481
+ [workspaceRoot]
482
+ );
483
+
484
+ const deleteLocalFile = useCallback(
485
+ (relPath: string) => {
486
+ try {
487
+ const abs = join(workspaceRoot, relPath);
488
+ unlinkSync(abs);
489
+ localFileCache.current.delete(relPath);
490
+ } catch {
491
+ // File may not exist; ignore.
492
+ }
493
+ },
494
+ [workspaceRoot]
495
+ );
496
+
497
+ const [trustedWorkspace, setTrustedWorkspace] = useState(() => isWorkspaceTrusted(workspaceRoot));
498
+ const [trustSelection, setTrustSelection] = useState(0);
499
+ const [booting, setBooting] = useState(() => isWorkspaceTrusted(workspaceRoot));
500
+ const [bootError, setBootError] = useState<string | null>(null);
501
+ const [user, setUser] = useState<AuthSession | null>(null);
502
+ const [loginMode, setLoginMode] = useState<"login" | "signup">("login");
503
+ const [email, setEmail] = useState("");
504
+ const [password, setPassword] = useState("");
505
+ const [loginField, setLoginField] = useState<"email" | "password">("email");
506
+ const [loginError, setLoginError] = useState<string | null>(null);
507
+ const [authBusy, setAuthBusy] = useState(false);
508
+
509
+ const [messages, setMessages] = useState<UiMessage[]>([]);
510
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
511
+ const [sessionId, setSessionId] = useState<string | null>(null);
512
+ const [activeModel, setActiveModel] = useState(getDefaultModel());
513
+ const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
514
+ const [lastCreditCost, setLastCreditCost] = useState<number | null>(null);
515
+ const runtimeMode = getRuntimeMode();
516
+ const [prompt, setPrompt] = useState("");
517
+ const [thinking, setThinking] = useState(false);
518
+ const [streamingText, setStreamingText] = useState("");
519
+ const [voiceEnabled, setVoiceEnabled] = useState(false);
520
+ const [voiceListening, setVoiceListening] = useState(false);
521
+ const [historyOpen, setHistoryOpen] = useState(false);
522
+ const [historyMode, setHistoryMode] = useState<HistoryMode>("picker");
523
+ const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
524
+ const [historyLoading, setHistoryLoading] = useState(false);
525
+ const [historyQuery, setHistoryQuery] = useState("");
526
+ const [historyRows, setHistoryRows] = useState<SessionSummary[]>([]);
527
+ const [historyIndex, setHistoryIndex] = useState(0);
528
+ const liveVoiceRef = useRef<LiveTranscriptionController | null>(null);
529
+
530
+ const pushMessage = useCallback((kind: UiMessage["kind"], text: string) => {
531
+ setMessages((prev) => [...prev, {kind, text}].slice(-300));
532
+ }, []);
533
+
534
+ const filteredHistory = useMemo(() => {
535
+ const q = historyQuery.trim().toLowerCase();
536
+ if (!q) {
537
+ return historyRows;
538
+ }
539
+ return historyRows.filter((s) => s.title.toLowerCase().includes(q));
540
+ }, [historyQuery, historyRows]);
541
+
542
+ const HISTORY_PAGE_SIZE = 10;
543
+ const historyPage = Math.floor(historyIndex / HISTORY_PAGE_SIZE);
544
+ const historyPageCount = Math.max(1, Math.ceil(filteredHistory.length / HISTORY_PAGE_SIZE));
545
+ const pageStart = historyPage * HISTORY_PAGE_SIZE;
546
+ const pageRows = filteredHistory.slice(pageStart, pageStart + HISTORY_PAGE_SIZE);
547
+
548
+ const openExternalUrl = useCallback((url: string): boolean => {
549
+ try {
550
+ if (process.platform === "darwin") {
551
+ spawn("open", [url], {detached: true, stdio: "ignore"}).unref();
552
+ return true;
553
+ }
554
+ if (process.platform === "win32") {
555
+ spawn("cmd", ["/c", "start", "", url], {detached: true, stdio: "ignore"}).unref();
556
+ return true;
557
+ }
558
+ spawn("xdg-open", [url], {detached: true, stdio: "ignore"}).unref();
559
+ return true;
560
+ } catch {
561
+ return false;
562
+ }
563
+ }, []);
564
+
565
+ useEffect(() => {
566
+ if (historyIndex >= filteredHistory.length) {
567
+ setHistoryIndex(Math.max(filteredHistory.length - 1, 0));
568
+ }
569
+ }, [filteredHistory.length, historyIndex]);
570
+
571
+ const openHistory = useCallback(async () => {
572
+ setHistoryOpen(true);
573
+ setHistoryMode("picker");
574
+ setHistoryPickerIndex(0);
575
+ setHistoryQuery("");
576
+ setHistoryRows([]);
577
+ setHistoryIndex(0);
578
+ setHistoryLoading(false);
579
+ }, []);
580
+
581
+ const openChatHistoryList = useCallback(async () => {
582
+ if (!user) {
583
+ return;
584
+ }
585
+ setHistoryMode("sessions");
586
+ setHistoryLoading(true);
587
+ try {
588
+ const sessions = await backend.listSessions(user, 100);
589
+ setHistoryRows(sessions);
590
+ setHistoryIndex(0);
591
+ } catch (error) {
592
+ pushMessage("error", `Failed to load history: ${error instanceof Error ? error.message : String(error)}`);
593
+ setHistoryOpen(false);
594
+ } finally {
595
+ setHistoryLoading(false);
596
+ }
597
+ }, [backend, pushMessage, user]);
598
+
599
+ const stopLiveVoice = useCallback(async (): Promise<void> => {
600
+ const controller = liveVoiceRef.current;
601
+ if (!controller) {
602
+ return;
603
+ }
604
+ liveVoiceRef.current = null;
605
+ await controller.stop();
606
+ setVoiceListening(false);
607
+ }, []);
608
+
609
+ const startLiveVoice = useCallback((): void => {
610
+ if (liveVoiceRef.current) {
611
+ return;
612
+ }
613
+ setVoiceEnabled(true);
614
+ setVoiceListening(true);
615
+ pushMessage("system", "Live transcription started. Speak now…");
616
+ liveVoiceRef.current = startLiveTranscription({
617
+ onPartial: (text) => {
618
+ setPrompt(text);
619
+ },
620
+ onFinal: (text) => {
621
+ setPrompt(text);
622
+ },
623
+ onError: (error) => {
624
+ pushMessage("error", `Voice transcription error: ${error.message}`);
625
+ }
626
+ });
627
+ }, [pushMessage]);
628
+
629
+ useEffect(() => {
630
+ return () => {
631
+ const controller = liveVoiceRef.current;
632
+ if (controller) {
633
+ void controller.stop();
634
+ }
635
+ };
636
+ }, []);
637
+
638
+ useEffect(() => {
639
+ if (!trustedWorkspace) {
640
+ return;
641
+ }
642
+ let cancelled = false;
643
+ const bootstrap = async (): Promise<void> => {
644
+ const healthy = await backend.healthOk();
645
+ if (!healthy) {
646
+ if (!cancelled) {
647
+ setBootError("Backend is unreachable. Check ASTRA_BACKEND_URL and try again.");
648
+ setBooting(false);
649
+ }
650
+ return;
651
+ }
652
+
653
+ const cached = loadSession();
654
+ if (!cached) {
655
+ if (!cancelled) {
656
+ setBooting(false);
657
+ }
658
+ return;
659
+ }
660
+
661
+ const valid = await backend.validateSession(cached);
662
+ if (!valid) {
663
+ clearSession();
664
+ } else if (!cancelled) {
665
+ setUser(cached);
666
+ setMessages([
667
+ {kind: "system", text: "Welcome back. Type a message or /help."},
668
+ {kind: "system", text: ""}
669
+ ]);
670
+ }
671
+
672
+ if (!cancelled) {
673
+ setBooting(false);
674
+ }
675
+ };
676
+
677
+ void bootstrap();
678
+ return () => {
679
+ cancelled = true;
680
+ };
681
+ }, [backend, trustedWorkspace]);
682
+
683
+ useInput((input, key) => {
684
+ if (!trustedWorkspace) {
685
+ if (key.upArrow || input === "k") {
686
+ setTrustSelection(0);
687
+ return;
688
+ }
689
+ if (key.downArrow || input === "j") {
690
+ setTrustSelection(1);
691
+ return;
692
+ }
693
+ if (key.escape) {
694
+ exit();
695
+ return;
696
+ }
697
+ if (key.return) {
698
+ if (trustSelection === 0) {
699
+ trustWorkspace(workspaceRoot);
700
+ setTrustedWorkspace(true);
701
+ setBooting(true);
702
+ return;
703
+ }
704
+ exit();
705
+ }
706
+ return;
707
+ }
708
+
709
+ if (key.ctrl && input === "c") {
710
+ void stopLiveVoice();
711
+ exit();
712
+ return;
713
+ }
714
+
715
+ if (historyOpen) {
716
+ if (key.escape) {
717
+ if (historyMode === "sessions") {
718
+ setHistoryMode("picker");
719
+ setHistoryQuery("");
720
+ } else {
721
+ setHistoryOpen(false);
722
+ }
723
+ return;
724
+ }
725
+
726
+ if (historyMode === "picker") {
727
+ if (key.upArrow || input === "k") {
728
+ setHistoryPickerIndex((prev) => Math.max(prev - 1, 0));
729
+ return;
730
+ }
731
+ if (key.downArrow || input === "j") {
732
+ setHistoryPickerIndex((prev) => Math.min(prev + 1, 1));
733
+ return;
734
+ }
735
+ if (key.return) {
736
+ if (historyPickerIndex === 0) {
737
+ void openChatHistoryList();
738
+ } else {
739
+ const ok = openExternalUrl(HISTORY_SETTINGS_URL);
740
+ if (!ok) {
741
+ pushMessage("system", `Open this link for credit usage history: ${HISTORY_SETTINGS_URL}`);
742
+ } else {
743
+ pushMessage("system", "Opened credit usage history in your browser.");
744
+ }
745
+ setHistoryOpen(false);
746
+ setHistoryPickerIndex(0);
747
+ }
748
+ return;
749
+ }
750
+ return;
751
+ }
752
+
753
+ if (key.leftArrow || input === "h") {
754
+ setHistoryIndex((prev) => Math.max(prev - HISTORY_PAGE_SIZE, 0));
755
+ return;
756
+ }
757
+ if (key.rightArrow || input === "l") {
758
+ setHistoryIndex((prev) => Math.min(prev + HISTORY_PAGE_SIZE, Math.max(filteredHistory.length - 1, 0)));
759
+ return;
760
+ }
761
+ if (key.upArrow || input === "k") {
762
+ setHistoryIndex((prev) => Math.max(prev - 1, 0));
763
+ return;
764
+ }
765
+ if (key.downArrow || input === "j") {
766
+ setHistoryIndex((prev) => Math.min(prev + 1, Math.max(filteredHistory.length - 1, 0)));
767
+ return;
768
+ }
769
+ if (key.return) {
770
+ const selected = filteredHistory[historyIndex];
771
+ if (selected) {
772
+ void (async () => {
773
+ try {
774
+ const loaded = await backend.getSessionMessages(selected.id, 200);
775
+ setSessionId(selected.id);
776
+ setChatMessages(loaded);
777
+ setMessages(
778
+ loaded.flatMap((m) => [{kind: m.role === "user" ? "user" : "assistant", text: m.content}] as UiMessage[])
779
+ );
780
+ setHistoryOpen(false);
781
+ setHistoryQuery("");
782
+ setHistoryMode("picker");
783
+ pushMessage("system", "");
784
+ pushMessage("system", `Loaded "${selected.title}" (${loaded.length} messages).`);
785
+ } catch (error) {
786
+ pushMessage("error", `Failed to open session: ${error instanceof Error ? error.message : String(error)}`);
787
+ }
788
+ })();
789
+ }
790
+ return;
791
+ }
792
+ if (input === "d" || input === "x") {
793
+ const selected = filteredHistory[historyIndex];
794
+ if (selected) {
795
+ void (async () => {
796
+ try {
797
+ await backend.deleteSession(selected.id);
798
+ setHistoryRows((prev) => prev.filter((row) => row.id !== selected.id));
799
+ pushMessage("tool", `Archived session: ${selected.title}`);
800
+ } catch (error) {
801
+ pushMessage("error", `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`);
802
+ }
803
+ })();
804
+ }
805
+ }
806
+ return;
807
+ }
808
+
809
+ if (!user && key.ctrl && input.toLowerCase() === "t") {
810
+ setLoginMode((prev) => (prev === "login" ? "signup" : "login"));
811
+ setLoginError(null);
812
+ return;
813
+ }
814
+ });
815
+
816
+ const doAuth = useCallback(async () => {
817
+ if (!email.trim() || !password.trim()) {
818
+ setLoginError("Email and password are required.");
819
+ return;
820
+ }
821
+
822
+ setAuthBusy(true);
823
+ setLoginError(null);
824
+ try {
825
+ const endpoint = loginMode === "login" ? "/api/auth/sign-in" : "/api/auth/sign-up";
826
+ const data = await backend.post(endpoint, {
827
+ email: email.trim(),
828
+ password: password.trim()
829
+ });
830
+
831
+ if (typeof data.error === "string" && data.error) {
832
+ throw new Error(data.error);
833
+ }
834
+
835
+ const authSession = data as AuthSession;
836
+ saveSession(authSession);
837
+ setUser(authSession);
838
+ setEmail("");
839
+ setPassword("");
840
+ setMessages([
841
+ {kind: "system", text: "Welcome. Type a message or /help."},
842
+ {kind: "system", text: ""}
843
+ ]);
844
+ } catch (error) {
845
+ setLoginError(error instanceof Error ? error.message : String(error));
846
+ } finally {
847
+ setAuthBusy(false);
848
+ }
849
+ }, [backend, email, loginMode, password]);
850
+
851
+ const handleEvent = useCallback(
852
+ async (event: AgentEvent, activeSessionId: string): Promise<string | null> => {
853
+ const assistantPiece = extractAssistantText(event);
854
+ if (assistantPiece && !["tool_result", "error", "credits_update", "credits_exhausted"].includes(event.type)) {
855
+ return assistantPiece;
856
+ }
857
+
858
+ if (event.type === "run_in_terminal") {
859
+ const command = typeof event.command === "string" ? event.command : "";
860
+ const cwd = typeof event.cwd === "string" ? event.cwd : ".";
861
+ const blocking = typeof event.blocking === "boolean" ? event.blocking : true;
862
+ const terminalId = typeof event.terminal_id === "string" ? event.terminal_id : undefined;
863
+ const result = runTerminalCommand(command, cwd, blocking, process.cwd());
864
+
865
+ if (terminalId) {
866
+ await backend.post("/api/agent/terminal-result", {
867
+ terminal_id: terminalId,
868
+ session_id: activeSessionId,
869
+ exit_code: result.exit_code,
870
+ output: result.output,
871
+ cancelled: result.cancelled
872
+ });
873
+ }
874
+
875
+ pushMessage("tool", `[LOCAL] ▸ ran: ${command.slice(0, 60)}...`);
876
+ return null;
877
+ }
878
+
879
+ // Apply file operations to the local workspace immediately.
880
+ if (event.type === "tool_result" && event.success) {
881
+ const d = (event.data ?? {}) as Record<string, unknown>;
882
+ const resultType = (event as Record<string, unknown>).result_type as string | undefined;
883
+ const relPath = typeof d.path === "string" ? d.path : null;
884
+ const lang = typeof d.language === "string" ? d.language : "plaintext";
885
+
886
+ if (relPath) {
887
+ if (resultType === "file_create") {
888
+ const content = typeof d.content === "string" ? d.content : "";
889
+ writeLocalFile(relPath, content, lang);
890
+ pushMessage("tool", `[LOCAL] ✓ wrote ${relPath}`);
891
+ } else if (resultType === "file_edit") {
892
+ const content =
893
+ typeof d.full_new_content === "string"
894
+ ? d.full_new_content
895
+ : typeof d.new_content === "string"
896
+ ? d.new_content
897
+ : null;
898
+ if (content !== null) {
899
+ writeLocalFile(relPath, content, lang);
900
+ pushMessage("tool", `[LOCAL] ✓ edited ${relPath}`);
901
+ }
902
+ } else if (resultType === "file_delete") {
903
+ deleteLocalFile(relPath);
904
+ pushMessage("tool", `[LOCAL] ✓ deleted ${relPath}`);
905
+ }
906
+ }
907
+ }
908
+
909
+ if (event.type === "credits_update") {
910
+ const remaining = Number(event.remaining ?? 0);
911
+ const cost = Number(event.cost ?? 0);
912
+ if (!Number.isNaN(remaining)) {
913
+ setCreditsRemaining(remaining);
914
+ }
915
+ if (!Number.isNaN(cost)) {
916
+ setLastCreditCost(cost);
917
+ }
918
+ return null;
919
+ }
920
+
921
+ if (event.type === "credits_exhausted") {
922
+ setCreditsRemaining(0);
923
+ }
924
+
925
+ const toolLine = eventToToolLine(event);
926
+ if (toolLine) {
927
+ if (event.type === "error" || event.type === "credits_exhausted") {
928
+ pushMessage("error", toolLine);
929
+ } else {
930
+ pushMessage("tool", toolLine);
931
+ }
932
+ } else if (event.type !== "thinking") {
933
+ const raw = JSON.stringify(event);
934
+ pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
935
+ }
936
+ return null;
937
+ },
938
+ [backend, deleteLocalFile, pushMessage, writeLocalFile]
939
+ );
940
+
941
+ const sendPrompt = useCallback(
942
+ async (rawPrompt: string) => {
943
+ const text = rawPrompt.trim();
944
+ if (!text || !user || thinking) {
945
+ return;
946
+ }
947
+
948
+ if (text === "/help") {
949
+ pushMessage(
950
+ "system",
951
+ "/new /history /voice on|off|status|start|stop|input /settings /settings model <id> /logout /exit"
952
+ );
953
+ pushMessage("system", "");
954
+ return;
955
+ }
956
+ if (text === "/settings") {
957
+ pushMessage(
958
+ "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()}`
960
+ );
961
+ pushMessage("system", "");
962
+ return;
963
+ }
964
+ if (text.startsWith("/settings model ")) {
965
+ const nextModel = text.replace("/settings model ", "").trim();
966
+ if (!nextModel) {
967
+ pushMessage("error", "Usage: /settings model <model-id>");
968
+ return;
969
+ }
970
+ setActiveModel(nextModel);
971
+ setSessionId(null);
972
+ setChatMessages([]);
973
+ pushMessage(
974
+ "system",
975
+ `Model switched to ${nextModel} (provider ${getProviderForModel(nextModel)}). Started a new chat session.`
976
+ );
977
+ pushMessage("system", "");
978
+ return;
979
+ }
980
+ if (text === "/history") {
981
+ await openHistory();
982
+ return;
983
+ }
984
+ if (text === "/voice status") {
985
+ pushMessage(
986
+ "system",
987
+ `Voice mode is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}.`
988
+ );
989
+ pushMessage("system", "");
990
+ return;
991
+ }
992
+ if (text === "/voice on") {
993
+ setVoiceEnabled(true);
994
+ pushMessage("system", "Voice mode enabled (assistant responses will be spoken).");
995
+ pushMessage("system", "");
996
+ return;
997
+ }
998
+ if (text === "/voice off") {
999
+ await stopLiveVoice();
1000
+ 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.");
1020
+ pushMessage("system", "");
1021
+ return;
1022
+ }
1023
+ if (text === "/voice input") {
1024
+ const transcribed = await transcribeOnce();
1025
+ if (!transcribed) {
1026
+ pushMessage(
1027
+ "error",
1028
+ "No speech transcribed. Set ASTRA_STT_COMMAND to a command that prints transcript text to stdout."
1029
+ );
1030
+ return;
1031
+ }
1032
+ pushMessage("tool", `[LOCAL] 🎙 ${transcribed}`);
1033
+ setPrompt(transcribed);
1034
+ pushMessage("system", "Transcribed input ready. Press Enter to send.");
1035
+ return;
1036
+ }
1037
+ if (text === "/new") {
1038
+ setSessionId(null);
1039
+ setChatMessages([]);
1040
+ setStreamingText("");
1041
+ pushMessage("system", "Started new chat.");
1042
+ pushMessage("system", "");
1043
+ return;
1044
+ }
1045
+ if (text === "/logout") {
1046
+ await stopLiveVoice();
1047
+ clearSession();
1048
+ setUser(null);
1049
+ setMessages([]);
1050
+ setChatMessages([]);
1051
+ setSessionId(null);
1052
+ return;
1053
+ }
1054
+ if (text === "/exit" || text === "/quit") {
1055
+ await stopLiveVoice();
1056
+ exit();
1057
+ return;
1058
+ }
1059
+
1060
+ let activeSessionId = sessionId;
1061
+ if (!activeSessionId) {
1062
+ activeSessionId = await backend.ensureSessionId(user, null, activeModel);
1063
+ setSessionId(activeSessionId);
1064
+ }
1065
+
1066
+ const nextChatMessages = [...chatMessages, {role: "user", content: text} as ChatMessage];
1067
+ setChatMessages(nextChatMessages);
1068
+ pushMessage("user", text);
1069
+ setThinking(true);
1070
+ setStreamingText("");
1071
+
1072
+ try {
1073
+ // Scan the local workspace so the backend VirtualFS is populated.
1074
+ // Merge in any files created/edited during this session so edits
1075
+ // persist across agent turns within the same chat.
1076
+ const {workspaceTree, workspaceFiles: scannedFiles} = scanWorkspace(workspaceRoot);
1077
+ const sessionFiles = Array.from(localFileCache.current.values());
1078
+ const seenPaths = new Set(sessionFiles.map((f) => f.path));
1079
+ const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
1080
+
1081
+ let assistant = "";
1082
+ let localActionConfirmed = false;
1083
+ for await (const event of backend.streamChat({
1084
+ user,
1085
+ sessionId: activeSessionId,
1086
+ messages: nextChatMessages,
1087
+ workspaceRoot,
1088
+ workspaceTree,
1089
+ workspaceFiles: mergedFiles,
1090
+ model: activeModel
1091
+ })) {
1092
+ if (event.type === "run_in_terminal") {
1093
+ localActionConfirmed = true;
1094
+ }
1095
+ if (event.type === "tool_result") {
1096
+ const payload = (event.data ?? {}) as Record<string, unknown>;
1097
+ if (payload.local === true) {
1098
+ localActionConfirmed = true;
1099
+ }
1100
+ }
1101
+ const piece = await handleEvent(event, activeSessionId);
1102
+ if (piece) {
1103
+ assistant += piece;
1104
+ setStreamingText(normalizeAssistantText(assistant));
1105
+ }
1106
+ }
1107
+
1108
+ setStreamingText("");
1109
+ if (assistant.trim()) {
1110
+ const cleanedAssistant = normalizeAssistantText(assistant);
1111
+ const guardedAssistant =
1112
+ !localActionConfirmed && looksLikeLocalFilesystemClaim(cleanedAssistant)
1113
+ ? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
1114
+ : cleanedAssistant;
1115
+ pushMessage("assistant", guardedAssistant);
1116
+ if (voiceEnabled) {
1117
+ speakText(guardedAssistant);
1118
+ }
1119
+ setChatMessages((prev) => [...prev, {role: "assistant", content: cleanedAssistant}]);
1120
+ } else {
1121
+ setChatMessages((prev) => [...prev, {role: "assistant", content: assistant}]);
1122
+ }
1123
+ pushMessage("system", "");
1124
+ } catch (error) {
1125
+ pushMessage("error", `Error: ${error instanceof Error ? error.message : String(error)}`);
1126
+ } finally {
1127
+ setThinking(false);
1128
+ }
1129
+ },
1130
+ [
1131
+ activeModel,
1132
+ backend,
1133
+ chatMessages,
1134
+ exit,
1135
+ handleEvent,
1136
+ localFileCache,
1137
+ openHistory,
1138
+ pushMessage,
1139
+ sessionId,
1140
+ startLiveVoice,
1141
+ stopLiveVoice,
1142
+ thinking,
1143
+ user,
1144
+ voiceEnabled,
1145
+ voiceListening,
1146
+ workspaceRoot
1147
+ ]
1148
+ );
1149
+
1150
+ if (!trustedWorkspace) {
1151
+ return (
1152
+ <Box flexDirection="column">
1153
+ <Text color="#c0c9db">claude</Text>
1154
+ <Text color="#8ea1bd">
1155
+ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1156
+ </Text>
1157
+ <Box marginTop={1}>
1158
+ <Text color="#f0f4ff">Do you trust the files in this folder?</Text>
1159
+ </Box>
1160
+ <Box marginTop={1}>
1161
+ <Text color="#c8d5f0">{workspaceRoot}</Text>
1162
+ </Box>
1163
+ <Box marginTop={1}>
1164
+ <Text color="#8ea1bd">
1165
+ Astra Code may read, write, or execute files contained in this directory. This can pose security risks, so only use files from trusted
1166
+ sources.
1167
+ </Text>
1168
+ </Box>
1169
+ <Box marginTop={1}>
1170
+ <Text color="#7aa2ff">Learn more</Text>
1171
+ </Box>
1172
+ <Box marginTop={1} flexDirection="column">
1173
+ <Text color={trustSelection === 0 ? "#f0f4ff" : "#8ea1bd"}>
1174
+ {trustSelection === 0 ? "❯ " : " "}1. Yes, proceed
1175
+ </Text>
1176
+ <Text color={trustSelection === 1 ? "#f0f4ff" : "#8ea1bd"}>
1177
+ {trustSelection === 1 ? "❯ " : " "}2. No, exit
1178
+ </Text>
1179
+ </Box>
1180
+ <Box marginTop={1}>
1181
+ <Text color="#8ea1bd">Enter to confirm · Esc to cancel</Text>
1182
+ </Box>
1183
+ </Box>
1184
+ );
1185
+ }
1186
+
1187
+ if (booting) {
1188
+ return (
1189
+ <Box flexDirection="column">
1190
+ <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1191
+ <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1192
+ <Text color="#8aa2c9">
1193
+ <Spinner type="dots12" /> Booting Astra terminal shell...
1194
+ </Text>
1195
+ </Box>
1196
+ );
1197
+ }
1198
+
1199
+ if (bootError) {
1200
+ return (
1201
+ <Box flexDirection="column">
1202
+ <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1203
+ <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1204
+ <Text color="red">{bootError}</Text>
1205
+ </Box>
1206
+ );
1207
+ }
1208
+
1209
+ if (!user) {
1210
+ return (
1211
+ <Box flexDirection="column">
1212
+ <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1213
+ <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1214
+ <Text color="#b8c8ff">
1215
+ Astra terminal AI pair programmer ({loginMode === "login" ? "Sign in" : "Create account"})
1216
+ </Text>
1217
+ <Text color="#7c8ea8">Press Ctrl+T to toggle Sign in / Create account</Text>
1218
+ <Box marginTop={1}>
1219
+ <Text color="#93a3b8">Email: </Text>
1220
+ <TextInput
1221
+ value={email}
1222
+ onChange={setEmail}
1223
+ focus={loginField === "email"}
1224
+ onSubmit={() => {
1225
+ setLoginField("password");
1226
+ }}
1227
+ />
1228
+ </Box>
1229
+ <Box>
1230
+ <Text color="#93a3b8">Password: </Text>
1231
+ <TextInput
1232
+ value={password}
1233
+ onChange={setPassword}
1234
+ mask="*"
1235
+ focus={loginField === "password"}
1236
+ onSubmit={() => {
1237
+ void doAuth();
1238
+ }}
1239
+ />
1240
+ </Box>
1241
+ {authBusy ? (
1242
+ <Text color="#a5d6ff">
1243
+ <Spinner type="aesthetic" /> Syncing star map...
1244
+ </Text>
1245
+ ) : (
1246
+ <Text color="#7c8ea8">Enter email, press Enter, then submit password.</Text>
1247
+ )}
1248
+ {loginError ? <Text color="red">{loginError}</Text> : null}
1249
+ </Box>
1250
+ );
1251
+ }
1252
+
1253
+ if (historyOpen) {
1254
+ const selected = filteredHistory[historyIndex];
1255
+ return (
1256
+ <Box flexDirection="column">
1257
+ <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1258
+ <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1259
+ <Text color="#2a3a50">{DIVIDER}</Text>
1260
+ {historyMode === "picker" ? (
1261
+ <Box flexDirection="column">
1262
+ <Box flexDirection="row" justifyContent="space-between">
1263
+ <Text color="#dce9ff">History Picker</Text>
1264
+ <Text color="#5a7a9a">Esc close · Enter select</Text>
1265
+ </Box>
1266
+ <Text color="#2a3a50">{DIVIDER}</Text>
1267
+ <Box marginTop={1} flexDirection="column">
1268
+ <Box flexDirection="row">
1269
+ <Text color={historyPickerIndex === 0 ? "#dce9ff" : "#7a9bba"}>
1270
+ {historyPickerIndex === 0 ? "❯ " : " "}View chat history
1271
+ </Text>
1272
+ </Box>
1273
+ <Box marginTop={1} flexDirection="row">
1274
+ <Text color={historyPickerIndex === 1 ? "#dce9ff" : "#7a9bba"}>
1275
+ {historyPickerIndex === 1 ? "❯ " : " "}View credit usage history
1276
+ </Text>
1277
+ </Box>
1278
+ </Box>
1279
+ <Text color="#2a3a50">{DIVIDER}</Text>
1280
+ <Text color="#5a7a9a">Credit usage history opens: {HISTORY_SETTINGS_URL}</Text>
1281
+ </Box>
1282
+ ) : (
1283
+ <Box flexDirection="column">
1284
+ <Box flexDirection="row" justifyContent="space-between">
1285
+ <Text color="#dce9ff">Chat History</Text>
1286
+ <Text color="#5a7a9a">Esc back · Enter open · D delete · ←/→ page</Text>
1287
+ </Box>
1288
+ <Box marginTop={1} flexDirection="row">
1289
+ <Text color="#4a6070">search </Text>
1290
+ <TextInput value={historyQuery} onChange={setHistoryQuery} placeholder="Filter chats..." />
1291
+ </Box>
1292
+ <Text color="#2a3a50">{DIVIDER}</Text>
1293
+ {historyLoading ? (
1294
+ <Text color="#8aa2c9">
1295
+ <Spinner type="dots12" /> Loading chat history...
1296
+ </Text>
1297
+ ) : filteredHistory.length === 0 ? (
1298
+ <Text color="#8ea1bd">No sessions found.</Text>
1299
+ ) : (
1300
+ <Box flexDirection="column" marginTop={1}>
1301
+ {pageRows.map((row, localIdx) => {
1302
+ const idx = pageStart + localIdx;
1303
+ const active = idx === historyIndex;
1304
+ return (
1305
+ <Box key={row.id} flexDirection="row" justifyContent="space-between">
1306
+ <Text color={active ? "#dce9ff" : "#7a9bba"}>
1307
+ {active ? "❯ " : " "}
1308
+ {(row.title || "Untitled").slice(0, 58).padEnd(60, " ")}
1309
+ </Text>
1310
+ <Text color="#5a7a9a">
1311
+ {String(row.total_messages ?? 0).padStart(3, " ")} msgs · {formatSessionDate(row.updated_at)}
1312
+ </Text>
1313
+ </Box>
1314
+ );
1315
+ })}
1316
+ </Box>
1317
+ )}
1318
+ <Text color="#2a3a50">{DIVIDER}</Text>
1319
+ <Box flexDirection="row" justifyContent="space-between">
1320
+ <Text color="#5a7a9a">
1321
+ Page {historyPage + 1} / {historyPageCount}
1322
+ </Text>
1323
+ <Text color="#5a7a9a">Selected: {selected ? selected.id : "--"}</Text>
1324
+ </Box>
1325
+ </Box>
1326
+ )}
1327
+ </Box>
1328
+ );
1329
+ }
1330
+
1331
+ return (
1332
+ <Box flexDirection="column">
1333
+ <Text color="#7aa2ff">{ASTRA_ASCII}</Text>
1334
+ <Text color="#8ea1bd">{FOUNDER_WELCOME}</Text>
1335
+ {/*<Text color="#9aa8c1">Astra Code · {process.cwd()}</Text>*/}
1336
+ <Text color="#2a3a50">{DIVIDER}</Text>
1337
+ <Box flexDirection="row" gap={2}>
1338
+ <Box flexDirection="row">
1339
+ <Text color="#4a6070">mode </Text>
1340
+ <Text color="#9ad5ff">{runtimeMode}</Text>
1341
+ </Box>
1342
+ <Box flexDirection="row">
1343
+ <Text color="#4a6070">scope </Text>
1344
+ <Text color="#7a9bba">{workspaceRoot}</Text>
1345
+ </Box>
1346
+ <Box flexDirection="row">
1347
+ <Text color="#4a6070">provider </Text>
1348
+ <Text color="#9ad5ff">{getProviderForModel(activeModel)}</Text>
1349
+ </Box>
1350
+ <Box flexDirection="row">
1351
+ <Text color="#4a6070">credits </Text>
1352
+ <Text color={creditsRemaining !== null && creditsRemaining < 50 ? "#ffaa55" : "#9ad5ff"}>
1353
+ {creditsRemaining ?? "--"}
1354
+ {lastCreditCost !== null ? (
1355
+ <Text color="#5a7a9a"> (-{lastCreditCost})</Text>
1356
+ ) : null}
1357
+ </Text>
1358
+ </Box>
1359
+ <Box flexDirection="row">
1360
+ <Text color="#4a6070">model </Text>
1361
+ <Text color="#9ad5ff">{activeModel}</Text>
1362
+ </Box>
1363
+ <Box flexDirection="row">
1364
+ <Text color="#4a6070">voice </Text>
1365
+ <Text color={voiceEnabled ? "#9ad5ff" : "#5a7a9a"}>
1366
+ {voiceEnabled ? (voiceListening ? "on/listening" : "on") : "off"}
1367
+ </Text>
1368
+ </Box>
1369
+ </Box>
1370
+ <Text color="#2a3a50">{DIVIDER}</Text>
1371
+ <Text color="#3a5068">/help /new /history /voice on|off|status|start|stop|input /settings /logout /exit</Text>
1372
+ <Text color="#2a3a50">{DIVIDER}</Text>
1373
+ <Box flexDirection="column" marginTop={1}>
1374
+ {messages.map((message, index) => {
1375
+ const style = styleForKind(message.kind);
1376
+ const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
1377
+ const isSpacing = message.text === "" && message.kind === "system";
1378
+ if (isSpacing) {
1379
+ return <Box key={`${index}-spacer`} marginTop={0}><Text> </Text></Box>;
1380
+ }
1381
+ return (
1382
+ <Box key={`${index}-${message.kind}`} flexDirection="row">
1383
+ <Text color={style.labelColor} bold={style.bold}>
1384
+ {paddedLabel}
1385
+ </Text>
1386
+ {message.kind === "assistant" ? (
1387
+ <Box flexDirection="column">
1388
+ {renderMarkdownContent(message.text, style.textColor, `assistant-${index}`)}
1389
+ </Box>
1390
+ ) : (
1391
+ <Text color={style.textColor} bold={style.bold && message.kind === "error"}>
1392
+ {message.text}
1393
+ </Text>
1394
+ )}
1395
+ </Box>
1396
+ );
1397
+ })}
1398
+ {streamingText ? (
1399
+ <Box flexDirection="row">
1400
+ <Text color="#7aa2ff">{styleForKind("assistant").label.padEnd(LABEL_WIDTH, " ")}</Text>
1401
+ <Box flexDirection="column">{renderMarkdownContent(streamingText, "#dce9ff", "streaming")}</Box>
1402
+ </Box>
1403
+ ) : null}
1404
+ </Box>
1405
+ <Text color="#2a3a50">{DIVIDER}</Text>
1406
+ {thinking ? (
1407
+ <Box flexDirection="row" marginTop={1}>
1408
+ <Text color="#7aa2ff">{"◆ astra".padEnd(LABEL_WIDTH, " ")}</Text>
1409
+ <Text color="#6080a0">
1410
+ <Spinner type="dots2" />
1411
+ <Text color="#8aa2c9"> thinking...</Text>
1412
+ </Text>
1413
+ </Box>
1414
+ ) : null}
1415
+ <Box marginTop={1} flexDirection="row">
1416
+ <Text color="#7aa2ff">❯ </Text>
1417
+ <TextInput
1418
+ value={prompt}
1419
+ onChange={setPrompt}
1420
+ onSubmit={(value) => {
1421
+ setPrompt("");
1422
+ void sendPrompt(value);
1423
+ }}
1424
+ placeholder={voiceEnabled ? "Ask Astra... (/voice start for live transcription)" : "Ask Astra..."}
1425
+ />
1426
+ </Box>
1427
+ </Box>
1428
+ );
1429
+ };