@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.
- package/.env.example +19 -0
- package/README.md +109 -0
- package/dist/app/App.js +985 -0
- package/dist/app/App.js.map +1 -0
- package/dist/commands/cli.js +107 -0
- package/dist/commands/cli.js.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/backendClient.js +198 -0
- package/dist/lib/backendClient.js.map +1 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/sessionStore.js +24 -0
- package/dist/lib/sessionStore.js.map +1 -0
- package/dist/lib/terminalBridge.js +60 -0
- package/dist/lib/terminalBridge.js.map +1 -0
- package/dist/lib/trustStore.js +31 -0
- package/dist/lib/trustStore.js.map +1 -0
- package/dist/lib/voice.js +158 -0
- package/dist/lib/voice.js.map +1 -0
- package/dist/lib/workspaceScanner.js +158 -0
- package/dist/lib/workspaceScanner.js.map +1 -0
- package/dist/types/events.js +2 -0
- package/dist/types/events.js.map +1 -0
- package/docs/python-to-ts-parity.md +45 -0
- package/install.sh +32 -0
- package/package.json +38 -0
- package/src/app/App.tsx +1429 -0
- package/src/commands/cli.ts +117 -0
- package/src/index.ts +18 -0
- package/src/lib/backendClient.ts +252 -0
- package/src/lib/config.ts +29 -0
- package/src/lib/sessionStore.ts +27 -0
- package/src/lib/terminalBridge.ts +80 -0
- package/src/lib/trustStore.ts +40 -0
- package/src/lib/voice.ts +178 -0
- package/src/lib/workspaceScanner.ts +177 -0
- package/src/types/events.ts +33 -0
- package/tsconfig.json +24 -0
package/src/app/App.tsx
ADDED
|
@@ -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
|
+
};
|