@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/dist/app/App.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import TextInput from "ink-text-input";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { BackendClient } from "../lib/backendClient.js";
|
|
10
|
+
import { clearSession, loadSession, saveSession } from "../lib/sessionStore.js";
|
|
11
|
+
import { getBackendUrl, getDefaultClientId, getDefaultModel, getProviderForModel, getRuntimeMode } from "../lib/config.js";
|
|
12
|
+
import { runTerminalCommand } from "../lib/terminalBridge.js";
|
|
13
|
+
import { isWorkspaceTrusted, trustWorkspace } from "../lib/trustStore.js";
|
|
14
|
+
import { scanWorkspace } from "../lib/workspaceScanner.js";
|
|
15
|
+
import { speakText, startLiveTranscription, transcribeOnce } from "../lib/voice.js";
|
|
16
|
+
// const ASTRA_ASCII = `
|
|
17
|
+
// █████╗ ███████╗████████╗██████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
18
|
+
// ██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
19
|
+
// ███████║███████╗ ██║ ██████╔╝███████║ ██║ ██║ ██║██║ ██║█████╗
|
|
20
|
+
// ██╔══██║╚════██║ ██║ ██╔══██╗██╔══██║ ██║ ██║ ██║██║ ██║██╔══╝
|
|
21
|
+
// ██║ ██║███████║ ██║ ██║ ██║██║ ██║ ╚██████╗╚██████╔╝██████╔╝███████╗
|
|
22
|
+
// ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
|
|
23
|
+
// by Sean Donovan
|
|
24
|
+
// `;
|
|
25
|
+
const ASTRA_ASCII = `
|
|
26
|
+
::: :::::::: ::::::::::: ::::::::: ::: :::::::: :::::::: ::::::::: ::::::::::
|
|
27
|
+
:┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼: :┼:
|
|
28
|
+
┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼ ┼:┼
|
|
29
|
+
┼#┼┼:┼┼#┼┼: ┼#┼┼:┼┼#┼┼ ┼#┼ ┼#┼┼:┼┼#: ┼#┼┼:┼┼#┼┼: ┼#┼ ┼#┼ ┼:┼ ┼#┼ ┼:┼ ┼#┼┼:┼┼#
|
|
30
|
+
┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼ ┼#┼
|
|
31
|
+
#┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼# #┼#
|
|
32
|
+
### ### ######## ### ### ### ### ### ######## ######## ######### ##########
|
|
33
|
+
by Sean Donovan
|
|
34
|
+
`;
|
|
35
|
+
const WELCOME_WIDTH = 96;
|
|
36
|
+
const centerLine = (text, width = WELCOME_WIDTH) => {
|
|
37
|
+
const trimmed = text.trim();
|
|
38
|
+
if (trimmed.length >= width) {
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
const left = Math.floor((width - trimmed.length) / 2);
|
|
42
|
+
return `${" ".repeat(left)}${trimmed}`;
|
|
43
|
+
};
|
|
44
|
+
const FOUNDER_WELCOME = centerLine("Welcome to Astra from Astra CEO & Founder, Sean Donovan");
|
|
45
|
+
const HISTORY_SETTINGS_URL = "https://astra-web-builder.vercel.app/settings";
|
|
46
|
+
const eventToToolLine = (event) => {
|
|
47
|
+
if (event.type === "tool_start") {
|
|
48
|
+
const name = event.tool?.name ?? "tool";
|
|
49
|
+
return `↳ ${name} executing...`;
|
|
50
|
+
}
|
|
51
|
+
if (event.type === "tool_result") {
|
|
52
|
+
const success = Boolean(event.success);
|
|
53
|
+
const mark = success ? "✓" : "✗";
|
|
54
|
+
const toolName = event.tool_name ?? "tool";
|
|
55
|
+
const payload = (event.data ?? {});
|
|
56
|
+
const output = String(payload.output ??
|
|
57
|
+
payload.content ??
|
|
58
|
+
event.error ??
|
|
59
|
+
"");
|
|
60
|
+
const locality = payload.local === true ? "LOCAL" : "REMOTE";
|
|
61
|
+
return `[${locality}] ${mark} ${toolName} ${output.slice(0, 240)}`;
|
|
62
|
+
}
|
|
63
|
+
if (event.type === "credits_exhausted") {
|
|
64
|
+
return typeof event.message === "string" ? event.message : "Credits exhausted.";
|
|
65
|
+
}
|
|
66
|
+
if (event.type === "error") {
|
|
67
|
+
const error = typeof event.error === "string" ? event.error : "";
|
|
68
|
+
const content = typeof event.content === "string" ? event.content : "";
|
|
69
|
+
return error || content || "Unknown error";
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
};
|
|
73
|
+
const looksLikeLocalFilesystemClaim = (text) => {
|
|
74
|
+
const lower = text.toLowerCase();
|
|
75
|
+
const changeWord = lower.includes("created") ||
|
|
76
|
+
lower.includes("updated") ||
|
|
77
|
+
lower.includes("deleted") ||
|
|
78
|
+
lower.includes("wrote") ||
|
|
79
|
+
lower.includes("saved");
|
|
80
|
+
const fsWord = lower.includes("file") ||
|
|
81
|
+
lower.includes("directory") ||
|
|
82
|
+
lower.includes("folder") ||
|
|
83
|
+
lower.includes("workspace");
|
|
84
|
+
return changeWord && fsWord;
|
|
85
|
+
};
|
|
86
|
+
const extractAssistantText = (event) => {
|
|
87
|
+
if (event.type === "text") {
|
|
88
|
+
return typeof event.content === "string" ? event.content : "";
|
|
89
|
+
}
|
|
90
|
+
const asRecord = event;
|
|
91
|
+
const directContent = asRecord.content;
|
|
92
|
+
if (typeof directContent === "string" && directContent) {
|
|
93
|
+
return directContent;
|
|
94
|
+
}
|
|
95
|
+
const delta = asRecord.delta;
|
|
96
|
+
if (typeof delta === "string" && delta) {
|
|
97
|
+
return delta;
|
|
98
|
+
}
|
|
99
|
+
const text = asRecord.text;
|
|
100
|
+
if (typeof text === "string" && text) {
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
const data = asRecord.data;
|
|
104
|
+
if (data && typeof data === "object") {
|
|
105
|
+
const dataRecord = data;
|
|
106
|
+
const nestedText = dataRecord.text ?? dataRecord.content ?? dataRecord.delta;
|
|
107
|
+
if (typeof nestedText === "string" && nestedText) {
|
|
108
|
+
return nestedText;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
const DIVIDER = "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
|
|
114
|
+
const LABEL_WIDTH = 10;
|
|
115
|
+
const styleForKind = (kind) => {
|
|
116
|
+
switch (kind) {
|
|
117
|
+
case "assistant":
|
|
118
|
+
return { label: "◆ astra", labelColor: "#7aa2ff", textColor: "#dce9ff", bold: false };
|
|
119
|
+
case "user":
|
|
120
|
+
return { label: "▸ you", labelColor: "#9ad5ff", textColor: "#c8e0ff", bold: false };
|
|
121
|
+
case "tool":
|
|
122
|
+
return { label: "⚙ tool", labelColor: "#5a7a9a", textColor: "#7a9bba", bold: false };
|
|
123
|
+
case "error":
|
|
124
|
+
return { label: "✦ error", labelColor: "#ff6b6b", textColor: "#ffaaaa", bold: true };
|
|
125
|
+
default:
|
|
126
|
+
return { label: "· note", labelColor: "#506070", textColor: "#8ea1bd", bold: false };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const formatSessionDate = (dateStr) => {
|
|
130
|
+
if (!dateStr) {
|
|
131
|
+
return "unknown";
|
|
132
|
+
}
|
|
133
|
+
const d = new Date(dateStr);
|
|
134
|
+
if (Number.isNaN(d.getTime())) {
|
|
135
|
+
return dateStr;
|
|
136
|
+
}
|
|
137
|
+
const now = new Date();
|
|
138
|
+
const days = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
|
139
|
+
if (days <= 0)
|
|
140
|
+
return "Today";
|
|
141
|
+
if (days === 1)
|
|
142
|
+
return "Yesterday";
|
|
143
|
+
if (days < 7)
|
|
144
|
+
return `${days}d ago`;
|
|
145
|
+
return d.toLocaleDateString();
|
|
146
|
+
};
|
|
147
|
+
const normalizeAssistantText = (input) => {
|
|
148
|
+
if (!input) {
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
return input
|
|
152
|
+
// Remove control chars but preserve newlines/tabs.
|
|
153
|
+
.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
|
|
154
|
+
// Trim trailing spaces line-by-line.
|
|
155
|
+
.replace(/[ \t]+$/gm, "")
|
|
156
|
+
// Normalize excessive blank lines.
|
|
157
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
158
|
+
.trim();
|
|
159
|
+
};
|
|
160
|
+
const parseInline = (line) => {
|
|
161
|
+
const tokens = [];
|
|
162
|
+
const re = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_)/g;
|
|
163
|
+
let last = 0;
|
|
164
|
+
let m;
|
|
165
|
+
while ((m = re.exec(line)) !== null) {
|
|
166
|
+
if (m.index > last) {
|
|
167
|
+
tokens.push({ text: line.slice(last, m.index) });
|
|
168
|
+
}
|
|
169
|
+
const seg = m[0];
|
|
170
|
+
if (seg.startsWith("`")) {
|
|
171
|
+
tokens.push({ text: seg.slice(1, -1), code: true });
|
|
172
|
+
}
|
|
173
|
+
else if (seg.startsWith("**") || seg.startsWith("__")) {
|
|
174
|
+
tokens.push({ text: seg.slice(2, -2), bold: true });
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
tokens.push({ text: seg.slice(1, -1), italic: true });
|
|
178
|
+
}
|
|
179
|
+
last = re.lastIndex;
|
|
180
|
+
}
|
|
181
|
+
if (last < line.length) {
|
|
182
|
+
tokens.push({ text: line.slice(last) });
|
|
183
|
+
}
|
|
184
|
+
return tokens;
|
|
185
|
+
};
|
|
186
|
+
const parseTableRow = (row) => {
|
|
187
|
+
const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
188
|
+
return trimmed.split("|").map((cell) => cell.trim());
|
|
189
|
+
};
|
|
190
|
+
const wrapTextToWidth = (text, width) => {
|
|
191
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
192
|
+
if (!normalized) {
|
|
193
|
+
return [""];
|
|
194
|
+
}
|
|
195
|
+
if (normalized.length <= width) {
|
|
196
|
+
return [normalized];
|
|
197
|
+
}
|
|
198
|
+
const words = normalized.split(" ");
|
|
199
|
+
const lines = [];
|
|
200
|
+
let current = "";
|
|
201
|
+
for (const word of words) {
|
|
202
|
+
if (!word) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
206
|
+
if (candidate.length <= width) {
|
|
207
|
+
current = candidate;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (current) {
|
|
211
|
+
lines.push(current);
|
|
212
|
+
current = "";
|
|
213
|
+
}
|
|
214
|
+
if (word.length <= width) {
|
|
215
|
+
current = word;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// Hard-wrap long tokens (e.g. URLs) that exceed column width.
|
|
219
|
+
for (let i = 0; i < word.length; i += width) {
|
|
220
|
+
lines.push(word.slice(i, i + width));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (current) {
|
|
224
|
+
lines.push(current);
|
|
225
|
+
}
|
|
226
|
+
return lines.length > 0 ? lines : [""];
|
|
227
|
+
};
|
|
228
|
+
const renderMarkdownContent = (text, baseColor, keyPrefix) => {
|
|
229
|
+
const lines = text.split("\n");
|
|
230
|
+
const out = [];
|
|
231
|
+
let i = 0;
|
|
232
|
+
let inCode = false;
|
|
233
|
+
while (i < lines.length) {
|
|
234
|
+
const line = lines[i] ?? "";
|
|
235
|
+
const trimmed = line.trim();
|
|
236
|
+
if (trimmed.startsWith("```")) {
|
|
237
|
+
inCode = !inCode;
|
|
238
|
+
i += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (inCode) {
|
|
242
|
+
out.push(_jsx(Text, { color: "#9ad5ff", children: line }, `${keyPrefix}-code-${i}`));
|
|
243
|
+
i += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const next = lines[i + 1] ?? "";
|
|
247
|
+
const tableSep = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(next);
|
|
248
|
+
if (line.includes("|") && tableSep) {
|
|
249
|
+
const rows = [];
|
|
250
|
+
rows.push(parseTableRow(line));
|
|
251
|
+
i += 2; // skip header + separator
|
|
252
|
+
while (i < lines.length) {
|
|
253
|
+
const current = lines[i] ?? "";
|
|
254
|
+
if (!current.includes("|")) {
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
rows.push(parseTableRow(current));
|
|
258
|
+
i += 1;
|
|
259
|
+
}
|
|
260
|
+
const colCount = Math.max(...rows.map((r) => r.length));
|
|
261
|
+
const widths = new Array(colCount).fill(0).map((_, col) => {
|
|
262
|
+
const longest = Math.max(...rows.map((r) => (r[col] ?? "").length));
|
|
263
|
+
return Math.max(8, Math.min(24, longest));
|
|
264
|
+
});
|
|
265
|
+
const renderTableRow = (cells, key, color, bold = false) => {
|
|
266
|
+
const wrapped = widths.map((w, idx) => wrapTextToWidth(cells[idx] ?? "", w));
|
|
267
|
+
const height = Math.max(...wrapped.map((parts) => parts.length));
|
|
268
|
+
for (let rowLine = 0; rowLine < height; rowLine += 1) {
|
|
269
|
+
out.push(_jsx(Text, { color: color, bold: bold, children: `| ${widths
|
|
270
|
+
.map((w, idx) => (wrapped[idx]?.[rowLine] ?? "").padEnd(w, " "))
|
|
271
|
+
.join(" | ")} |` }, `${key}-${rowLine}`));
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const headerRow = rows[0] ?? [];
|
|
275
|
+
renderTableRow(headerRow, `${keyPrefix}-table-head-${i}`, "#c8e0ff", true);
|
|
276
|
+
out.push(_jsx(Text, { color: "#5a7a9a", children: `|-${widths.map((w) => "".padEnd(w, "-")).join("-|-")}-|` }, `${keyPrefix}-table-sep-${i}`));
|
|
277
|
+
rows.slice(1).forEach((r, idx) => {
|
|
278
|
+
renderTableRow(r, `${keyPrefix}-table-row-${i}-${idx}`, baseColor);
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
let work = line;
|
|
283
|
+
if (/^\s*>\s?/.test(work)) {
|
|
284
|
+
const quoteText = work.replace(/^\s*>\s?/, "");
|
|
285
|
+
const quoteTokens = parseInline(quoteText);
|
|
286
|
+
out.push(_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#5a7a9a", children: "\u258E " }), _jsx(Text, { color: "#a8bad3", italic: true, children: quoteTokens.map((t, idx) => (_jsx(Text, { bold: Boolean(t.bold), italic: true, color: t.code ? "#9ad5ff" : "#a8bad3", children: t.text }, `${keyPrefix}-quote-${i}-tok-${idx}`))) })] }, `${keyPrefix}-quote-${i}`));
|
|
287
|
+
i += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const numberedMatch = work.match(/^(\s*)(\d+\.)\s+(.*)$/);
|
|
291
|
+
if (numberedMatch) {
|
|
292
|
+
const [, indent, marker, body] = numberedMatch;
|
|
293
|
+
const tokens = parseInline(body ?? "");
|
|
294
|
+
out.push(_jsxs(Text, { color: baseColor, children: [indent, _jsx(Text, { color: "#9ad5ff", bold: true, children: marker }), " ", tokens.map((t, idx) => (_jsx(Text, { bold: Boolean(t.bold), italic: Boolean(t.italic), color: t.code ? "#9ad5ff" : baseColor, children: t.text }, `${keyPrefix}-ol-${i}-tok-${idx}`)))] }, `${keyPrefix}-ol-${i}`));
|
|
295
|
+
i += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (/^\s*[-*]\s+/.test(work)) {
|
|
299
|
+
const bulletBody = work.replace(/^\s*[-*]\s+/, "");
|
|
300
|
+
const tokens = parseInline(bulletBody);
|
|
301
|
+
out.push(_jsxs(Text, { color: baseColor, children: [_jsx(Text, { color: "#9ad5ff", children: "\u2022 " }), tokens.map((t, idx) => (_jsx(Text, { bold: Boolean(t.bold), italic: Boolean(t.italic), color: t.code ? "#9ad5ff" : baseColor, children: t.text }, `${keyPrefix}-ul-${i}-tok-${idx}`)))] }, `${keyPrefix}-ul-${i}`));
|
|
302
|
+
i += 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (/^\s{0,3}#{1,6}\s+/.test(work)) {
|
|
306
|
+
work = work.replace(/^\s{0,3}#{1,6}\s+/, "");
|
|
307
|
+
out.push(_jsx(Text, { color: "#dce9ff", bold: true, children: work }, `${keyPrefix}-h-${i}`));
|
|
308
|
+
i += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const tokens = parseInline(work);
|
|
312
|
+
out.push(_jsx(Text, { color: baseColor, children: tokens.map((t, idx) => (_jsx(Text, { bold: Boolean(t.bold), italic: Boolean(t.italic), color: t.code ? "#9ad5ff" : baseColor, children: t.text }, `${keyPrefix}-line-${i}-tok-${idx}`))) }, `${keyPrefix}-line-${i}`));
|
|
313
|
+
i += 1;
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
};
|
|
317
|
+
export const AstraApp = () => {
|
|
318
|
+
const workspaceRoot = useMemo(() => process.cwd(), []);
|
|
319
|
+
const backend = useMemo(() => new BackendClient(), []);
|
|
320
|
+
const { exit } = useApp();
|
|
321
|
+
// In-session file cache: tracks files created/edited so subsequent requests
|
|
322
|
+
// include their latest content in workspaceFiles (VirtualFS stays up to date).
|
|
323
|
+
const localFileCache = useRef(new Map());
|
|
324
|
+
const writeLocalFile = useCallback((relPath, content, language) => {
|
|
325
|
+
try {
|
|
326
|
+
const abs = join(workspaceRoot, relPath);
|
|
327
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
328
|
+
writeFileSync(abs, content, "utf-8");
|
|
329
|
+
localFileCache.current.set(relPath, { path: relPath, content, language });
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
// Best-effort — App.tsx callers handle the error message separately.
|
|
333
|
+
}
|
|
334
|
+
}, [workspaceRoot]);
|
|
335
|
+
const deleteLocalFile = useCallback((relPath) => {
|
|
336
|
+
try {
|
|
337
|
+
const abs = join(workspaceRoot, relPath);
|
|
338
|
+
unlinkSync(abs);
|
|
339
|
+
localFileCache.current.delete(relPath);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// File may not exist; ignore.
|
|
343
|
+
}
|
|
344
|
+
}, [workspaceRoot]);
|
|
345
|
+
const [trustedWorkspace, setTrustedWorkspace] = useState(() => isWorkspaceTrusted(workspaceRoot));
|
|
346
|
+
const [trustSelection, setTrustSelection] = useState(0);
|
|
347
|
+
const [booting, setBooting] = useState(() => isWorkspaceTrusted(workspaceRoot));
|
|
348
|
+
const [bootError, setBootError] = useState(null);
|
|
349
|
+
const [user, setUser] = useState(null);
|
|
350
|
+
const [loginMode, setLoginMode] = useState("login");
|
|
351
|
+
const [email, setEmail] = useState("");
|
|
352
|
+
const [password, setPassword] = useState("");
|
|
353
|
+
const [loginField, setLoginField] = useState("email");
|
|
354
|
+
const [loginError, setLoginError] = useState(null);
|
|
355
|
+
const [authBusy, setAuthBusy] = useState(false);
|
|
356
|
+
const [messages, setMessages] = useState([]);
|
|
357
|
+
const [chatMessages, setChatMessages] = useState([]);
|
|
358
|
+
const [sessionId, setSessionId] = useState(null);
|
|
359
|
+
const [activeModel, setActiveModel] = useState(getDefaultModel());
|
|
360
|
+
const [creditsRemaining, setCreditsRemaining] = useState(null);
|
|
361
|
+
const [lastCreditCost, setLastCreditCost] = useState(null);
|
|
362
|
+
const runtimeMode = getRuntimeMode();
|
|
363
|
+
const [prompt, setPrompt] = useState("");
|
|
364
|
+
const [thinking, setThinking] = useState(false);
|
|
365
|
+
const [streamingText, setStreamingText] = useState("");
|
|
366
|
+
const [voiceEnabled, setVoiceEnabled] = useState(false);
|
|
367
|
+
const [voiceListening, setVoiceListening] = useState(false);
|
|
368
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
369
|
+
const [historyMode, setHistoryMode] = useState("picker");
|
|
370
|
+
const [historyPickerIndex, setHistoryPickerIndex] = useState(0);
|
|
371
|
+
const [historyLoading, setHistoryLoading] = useState(false);
|
|
372
|
+
const [historyQuery, setHistoryQuery] = useState("");
|
|
373
|
+
const [historyRows, setHistoryRows] = useState([]);
|
|
374
|
+
const [historyIndex, setHistoryIndex] = useState(0);
|
|
375
|
+
const liveVoiceRef = useRef(null);
|
|
376
|
+
const pushMessage = useCallback((kind, text) => {
|
|
377
|
+
setMessages((prev) => [...prev, { kind, text }].slice(-300));
|
|
378
|
+
}, []);
|
|
379
|
+
const filteredHistory = useMemo(() => {
|
|
380
|
+
const q = historyQuery.trim().toLowerCase();
|
|
381
|
+
if (!q) {
|
|
382
|
+
return historyRows;
|
|
383
|
+
}
|
|
384
|
+
return historyRows.filter((s) => s.title.toLowerCase().includes(q));
|
|
385
|
+
}, [historyQuery, historyRows]);
|
|
386
|
+
const HISTORY_PAGE_SIZE = 10;
|
|
387
|
+
const historyPage = Math.floor(historyIndex / HISTORY_PAGE_SIZE);
|
|
388
|
+
const historyPageCount = Math.max(1, Math.ceil(filteredHistory.length / HISTORY_PAGE_SIZE));
|
|
389
|
+
const pageStart = historyPage * HISTORY_PAGE_SIZE;
|
|
390
|
+
const pageRows = filteredHistory.slice(pageStart, pageStart + HISTORY_PAGE_SIZE);
|
|
391
|
+
const openExternalUrl = useCallback((url) => {
|
|
392
|
+
try {
|
|
393
|
+
if (process.platform === "darwin") {
|
|
394
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (process.platform === "win32") {
|
|
398
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}, []);
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (historyIndex >= filteredHistory.length) {
|
|
410
|
+
setHistoryIndex(Math.max(filteredHistory.length - 1, 0));
|
|
411
|
+
}
|
|
412
|
+
}, [filteredHistory.length, historyIndex]);
|
|
413
|
+
const openHistory = useCallback(async () => {
|
|
414
|
+
setHistoryOpen(true);
|
|
415
|
+
setHistoryMode("picker");
|
|
416
|
+
setHistoryPickerIndex(0);
|
|
417
|
+
setHistoryQuery("");
|
|
418
|
+
setHistoryRows([]);
|
|
419
|
+
setHistoryIndex(0);
|
|
420
|
+
setHistoryLoading(false);
|
|
421
|
+
}, []);
|
|
422
|
+
const openChatHistoryList = useCallback(async () => {
|
|
423
|
+
if (!user) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
setHistoryMode("sessions");
|
|
427
|
+
setHistoryLoading(true);
|
|
428
|
+
try {
|
|
429
|
+
const sessions = await backend.listSessions(user, 100);
|
|
430
|
+
setHistoryRows(sessions);
|
|
431
|
+
setHistoryIndex(0);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
pushMessage("error", `Failed to load history: ${error instanceof Error ? error.message : String(error)}`);
|
|
435
|
+
setHistoryOpen(false);
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
setHistoryLoading(false);
|
|
439
|
+
}
|
|
440
|
+
}, [backend, pushMessage, user]);
|
|
441
|
+
const stopLiveVoice = useCallback(async () => {
|
|
442
|
+
const controller = liveVoiceRef.current;
|
|
443
|
+
if (!controller) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
liveVoiceRef.current = null;
|
|
447
|
+
await controller.stop();
|
|
448
|
+
setVoiceListening(false);
|
|
449
|
+
}, []);
|
|
450
|
+
const startLiveVoice = useCallback(() => {
|
|
451
|
+
if (liveVoiceRef.current) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
setVoiceEnabled(true);
|
|
455
|
+
setVoiceListening(true);
|
|
456
|
+
pushMessage("system", "Live transcription started. Speak now…");
|
|
457
|
+
liveVoiceRef.current = startLiveTranscription({
|
|
458
|
+
onPartial: (text) => {
|
|
459
|
+
setPrompt(text);
|
|
460
|
+
},
|
|
461
|
+
onFinal: (text) => {
|
|
462
|
+
setPrompt(text);
|
|
463
|
+
},
|
|
464
|
+
onError: (error) => {
|
|
465
|
+
pushMessage("error", `Voice transcription error: ${error.message}`);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
}, [pushMessage]);
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
return () => {
|
|
471
|
+
const controller = liveVoiceRef.current;
|
|
472
|
+
if (controller) {
|
|
473
|
+
void controller.stop();
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}, []);
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!trustedWorkspace) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
let cancelled = false;
|
|
482
|
+
const bootstrap = async () => {
|
|
483
|
+
const healthy = await backend.healthOk();
|
|
484
|
+
if (!healthy) {
|
|
485
|
+
if (!cancelled) {
|
|
486
|
+
setBootError("Backend is unreachable. Check ASTRA_BACKEND_URL and try again.");
|
|
487
|
+
setBooting(false);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const cached = loadSession();
|
|
492
|
+
if (!cached) {
|
|
493
|
+
if (!cancelled) {
|
|
494
|
+
setBooting(false);
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const valid = await backend.validateSession(cached);
|
|
499
|
+
if (!valid) {
|
|
500
|
+
clearSession();
|
|
501
|
+
}
|
|
502
|
+
else if (!cancelled) {
|
|
503
|
+
setUser(cached);
|
|
504
|
+
setMessages([
|
|
505
|
+
{ kind: "system", text: "Welcome back. Type a message or /help." },
|
|
506
|
+
{ kind: "system", text: "" }
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
if (!cancelled) {
|
|
510
|
+
setBooting(false);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
void bootstrap();
|
|
514
|
+
return () => {
|
|
515
|
+
cancelled = true;
|
|
516
|
+
};
|
|
517
|
+
}, [backend, trustedWorkspace]);
|
|
518
|
+
useInput((input, key) => {
|
|
519
|
+
if (!trustedWorkspace) {
|
|
520
|
+
if (key.upArrow || input === "k") {
|
|
521
|
+
setTrustSelection(0);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (key.downArrow || input === "j") {
|
|
525
|
+
setTrustSelection(1);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (key.escape) {
|
|
529
|
+
exit();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (key.return) {
|
|
533
|
+
if (trustSelection === 0) {
|
|
534
|
+
trustWorkspace(workspaceRoot);
|
|
535
|
+
setTrustedWorkspace(true);
|
|
536
|
+
setBooting(true);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
exit();
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (key.ctrl && input === "c") {
|
|
544
|
+
void stopLiveVoice();
|
|
545
|
+
exit();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (historyOpen) {
|
|
549
|
+
if (key.escape) {
|
|
550
|
+
if (historyMode === "sessions") {
|
|
551
|
+
setHistoryMode("picker");
|
|
552
|
+
setHistoryQuery("");
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
setHistoryOpen(false);
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (historyMode === "picker") {
|
|
560
|
+
if (key.upArrow || input === "k") {
|
|
561
|
+
setHistoryPickerIndex((prev) => Math.max(prev - 1, 0));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (key.downArrow || input === "j") {
|
|
565
|
+
setHistoryPickerIndex((prev) => Math.min(prev + 1, 1));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (key.return) {
|
|
569
|
+
if (historyPickerIndex === 0) {
|
|
570
|
+
void openChatHistoryList();
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
const ok = openExternalUrl(HISTORY_SETTINGS_URL);
|
|
574
|
+
if (!ok) {
|
|
575
|
+
pushMessage("system", `Open this link for credit usage history: ${HISTORY_SETTINGS_URL}`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
pushMessage("system", "Opened credit usage history in your browser.");
|
|
579
|
+
}
|
|
580
|
+
setHistoryOpen(false);
|
|
581
|
+
setHistoryPickerIndex(0);
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (key.leftArrow || input === "h") {
|
|
588
|
+
setHistoryIndex((prev) => Math.max(prev - HISTORY_PAGE_SIZE, 0));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (key.rightArrow || input === "l") {
|
|
592
|
+
setHistoryIndex((prev) => Math.min(prev + HISTORY_PAGE_SIZE, Math.max(filteredHistory.length - 1, 0)));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (key.upArrow || input === "k") {
|
|
596
|
+
setHistoryIndex((prev) => Math.max(prev - 1, 0));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (key.downArrow || input === "j") {
|
|
600
|
+
setHistoryIndex((prev) => Math.min(prev + 1, Math.max(filteredHistory.length - 1, 0)));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (key.return) {
|
|
604
|
+
const selected = filteredHistory[historyIndex];
|
|
605
|
+
if (selected) {
|
|
606
|
+
void (async () => {
|
|
607
|
+
try {
|
|
608
|
+
const loaded = await backend.getSessionMessages(selected.id, 200);
|
|
609
|
+
setSessionId(selected.id);
|
|
610
|
+
setChatMessages(loaded);
|
|
611
|
+
setMessages(loaded.flatMap((m) => [{ kind: m.role === "user" ? "user" : "assistant", text: m.content }]));
|
|
612
|
+
setHistoryOpen(false);
|
|
613
|
+
setHistoryQuery("");
|
|
614
|
+
setHistoryMode("picker");
|
|
615
|
+
pushMessage("system", "");
|
|
616
|
+
pushMessage("system", `Loaded "${selected.title}" (${loaded.length} messages).`);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
pushMessage("error", `Failed to open session: ${error instanceof Error ? error.message : String(error)}`);
|
|
620
|
+
}
|
|
621
|
+
})();
|
|
622
|
+
}
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (input === "d" || input === "x") {
|
|
626
|
+
const selected = filteredHistory[historyIndex];
|
|
627
|
+
if (selected) {
|
|
628
|
+
void (async () => {
|
|
629
|
+
try {
|
|
630
|
+
await backend.deleteSession(selected.id);
|
|
631
|
+
setHistoryRows((prev) => prev.filter((row) => row.id !== selected.id));
|
|
632
|
+
pushMessage("tool", `Archived session: ${selected.title}`);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
pushMessage("error", `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`);
|
|
636
|
+
}
|
|
637
|
+
})();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!user && key.ctrl && input.toLowerCase() === "t") {
|
|
643
|
+
setLoginMode((prev) => (prev === "login" ? "signup" : "login"));
|
|
644
|
+
setLoginError(null);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
const doAuth = useCallback(async () => {
|
|
649
|
+
if (!email.trim() || !password.trim()) {
|
|
650
|
+
setLoginError("Email and password are required.");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
setAuthBusy(true);
|
|
654
|
+
setLoginError(null);
|
|
655
|
+
try {
|
|
656
|
+
const endpoint = loginMode === "login" ? "/api/auth/sign-in" : "/api/auth/sign-up";
|
|
657
|
+
const data = await backend.post(endpoint, {
|
|
658
|
+
email: email.trim(),
|
|
659
|
+
password: password.trim()
|
|
660
|
+
});
|
|
661
|
+
if (typeof data.error === "string" && data.error) {
|
|
662
|
+
throw new Error(data.error);
|
|
663
|
+
}
|
|
664
|
+
const authSession = data;
|
|
665
|
+
saveSession(authSession);
|
|
666
|
+
setUser(authSession);
|
|
667
|
+
setEmail("");
|
|
668
|
+
setPassword("");
|
|
669
|
+
setMessages([
|
|
670
|
+
{ kind: "system", text: "Welcome. Type a message or /help." },
|
|
671
|
+
{ kind: "system", text: "" }
|
|
672
|
+
]);
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
setLoginError(error instanceof Error ? error.message : String(error));
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
setAuthBusy(false);
|
|
679
|
+
}
|
|
680
|
+
}, [backend, email, loginMode, password]);
|
|
681
|
+
const handleEvent = useCallback(async (event, activeSessionId) => {
|
|
682
|
+
const assistantPiece = extractAssistantText(event);
|
|
683
|
+
if (assistantPiece && !["tool_result", "error", "credits_update", "credits_exhausted"].includes(event.type)) {
|
|
684
|
+
return assistantPiece;
|
|
685
|
+
}
|
|
686
|
+
if (event.type === "run_in_terminal") {
|
|
687
|
+
const command = typeof event.command === "string" ? event.command : "";
|
|
688
|
+
const cwd = typeof event.cwd === "string" ? event.cwd : ".";
|
|
689
|
+
const blocking = typeof event.blocking === "boolean" ? event.blocking : true;
|
|
690
|
+
const terminalId = typeof event.terminal_id === "string" ? event.terminal_id : undefined;
|
|
691
|
+
const result = runTerminalCommand(command, cwd, blocking, process.cwd());
|
|
692
|
+
if (terminalId) {
|
|
693
|
+
await backend.post("/api/agent/terminal-result", {
|
|
694
|
+
terminal_id: terminalId,
|
|
695
|
+
session_id: activeSessionId,
|
|
696
|
+
exit_code: result.exit_code,
|
|
697
|
+
output: result.output,
|
|
698
|
+
cancelled: result.cancelled
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
pushMessage("tool", `[LOCAL] ▸ ran: ${command.slice(0, 60)}...`);
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
// Apply file operations to the local workspace immediately.
|
|
705
|
+
if (event.type === "tool_result" && event.success) {
|
|
706
|
+
const d = (event.data ?? {});
|
|
707
|
+
const resultType = event.result_type;
|
|
708
|
+
const relPath = typeof d.path === "string" ? d.path : null;
|
|
709
|
+
const lang = typeof d.language === "string" ? d.language : "plaintext";
|
|
710
|
+
if (relPath) {
|
|
711
|
+
if (resultType === "file_create") {
|
|
712
|
+
const content = typeof d.content === "string" ? d.content : "";
|
|
713
|
+
writeLocalFile(relPath, content, lang);
|
|
714
|
+
pushMessage("tool", `[LOCAL] ✓ wrote ${relPath}`);
|
|
715
|
+
}
|
|
716
|
+
else if (resultType === "file_edit") {
|
|
717
|
+
const content = typeof d.full_new_content === "string"
|
|
718
|
+
? d.full_new_content
|
|
719
|
+
: typeof d.new_content === "string"
|
|
720
|
+
? d.new_content
|
|
721
|
+
: null;
|
|
722
|
+
if (content !== null) {
|
|
723
|
+
writeLocalFile(relPath, content, lang);
|
|
724
|
+
pushMessage("tool", `[LOCAL] ✓ edited ${relPath}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else if (resultType === "file_delete") {
|
|
728
|
+
deleteLocalFile(relPath);
|
|
729
|
+
pushMessage("tool", `[LOCAL] ✓ deleted ${relPath}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (event.type === "credits_update") {
|
|
734
|
+
const remaining = Number(event.remaining ?? 0);
|
|
735
|
+
const cost = Number(event.cost ?? 0);
|
|
736
|
+
if (!Number.isNaN(remaining)) {
|
|
737
|
+
setCreditsRemaining(remaining);
|
|
738
|
+
}
|
|
739
|
+
if (!Number.isNaN(cost)) {
|
|
740
|
+
setLastCreditCost(cost);
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
if (event.type === "credits_exhausted") {
|
|
745
|
+
setCreditsRemaining(0);
|
|
746
|
+
}
|
|
747
|
+
const toolLine = eventToToolLine(event);
|
|
748
|
+
if (toolLine) {
|
|
749
|
+
if (event.type === "error" || event.type === "credits_exhausted") {
|
|
750
|
+
pushMessage("error", toolLine);
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
pushMessage("tool", toolLine);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
else if (event.type !== "thinking") {
|
|
757
|
+
const raw = JSON.stringify(event);
|
|
758
|
+
pushMessage("tool", `event ${event.type}: ${raw.slice(0, 220)}`);
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}, [backend, deleteLocalFile, pushMessage, writeLocalFile]);
|
|
762
|
+
const sendPrompt = useCallback(async (rawPrompt) => {
|
|
763
|
+
const text = rawPrompt.trim();
|
|
764
|
+
if (!text || !user || thinking) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (text === "/help") {
|
|
768
|
+
pushMessage("system", "/new /history /voice on|off|status|start|stop|input /settings /settings model <id> /logout /exit");
|
|
769
|
+
pushMessage("system", "");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (text === "/settings") {
|
|
773
|
+
pushMessage("system", `Settings: mode=${runtimeMode} scope=${workspaceRoot} model=${activeModel} provider=${getProviderForModel(activeModel)} voice=${voiceEnabled ? "on" : "off"} listening=${voiceListening ? "yes" : "no"} client_id=${getDefaultClientId()} backend=${getBackendUrl()}`);
|
|
774
|
+
pushMessage("system", "");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (text.startsWith("/settings model ")) {
|
|
778
|
+
const nextModel = text.replace("/settings model ", "").trim();
|
|
779
|
+
if (!nextModel) {
|
|
780
|
+
pushMessage("error", "Usage: /settings model <model-id>");
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
setActiveModel(nextModel);
|
|
784
|
+
setSessionId(null);
|
|
785
|
+
setChatMessages([]);
|
|
786
|
+
pushMessage("system", `Model switched to ${nextModel} (provider ${getProviderForModel(nextModel)}). Started a new chat session.`);
|
|
787
|
+
pushMessage("system", "");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (text === "/history") {
|
|
791
|
+
await openHistory();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (text === "/voice status") {
|
|
795
|
+
pushMessage("system", `Voice mode is ${voiceEnabled ? "on" : "off"}${voiceListening ? " (live transcription active)" : ""}.`);
|
|
796
|
+
pushMessage("system", "");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (text === "/voice on") {
|
|
800
|
+
setVoiceEnabled(true);
|
|
801
|
+
pushMessage("system", "Voice mode enabled (assistant responses will be spoken).");
|
|
802
|
+
pushMessage("system", "");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (text === "/voice off") {
|
|
806
|
+
await stopLiveVoice();
|
|
807
|
+
setVoiceEnabled(false);
|
|
808
|
+
pushMessage("system", "Voice mode disabled.");
|
|
809
|
+
pushMessage("system", "");
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (text === "/voice start") {
|
|
813
|
+
if (voiceListening) {
|
|
814
|
+
pushMessage("system", "Live transcription is already running.");
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
startLiveVoice();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (text === "/voice stop") {
|
|
821
|
+
if (!voiceListening) {
|
|
822
|
+
pushMessage("system", "Live transcription is not running.");
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
await stopLiveVoice();
|
|
826
|
+
pushMessage("system", "Live transcription stopped. Press Enter to send transcript.");
|
|
827
|
+
pushMessage("system", "");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (text === "/voice input") {
|
|
831
|
+
const transcribed = await transcribeOnce();
|
|
832
|
+
if (!transcribed) {
|
|
833
|
+
pushMessage("error", "No speech transcribed. Set ASTRA_STT_COMMAND to a command that prints transcript text to stdout.");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
pushMessage("tool", `[LOCAL] 🎙 ${transcribed}`);
|
|
837
|
+
setPrompt(transcribed);
|
|
838
|
+
pushMessage("system", "Transcribed input ready. Press Enter to send.");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (text === "/new") {
|
|
842
|
+
setSessionId(null);
|
|
843
|
+
setChatMessages([]);
|
|
844
|
+
setStreamingText("");
|
|
845
|
+
pushMessage("system", "Started new chat.");
|
|
846
|
+
pushMessage("system", "");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (text === "/logout") {
|
|
850
|
+
await stopLiveVoice();
|
|
851
|
+
clearSession();
|
|
852
|
+
setUser(null);
|
|
853
|
+
setMessages([]);
|
|
854
|
+
setChatMessages([]);
|
|
855
|
+
setSessionId(null);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (text === "/exit" || text === "/quit") {
|
|
859
|
+
await stopLiveVoice();
|
|
860
|
+
exit();
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
let activeSessionId = sessionId;
|
|
864
|
+
if (!activeSessionId) {
|
|
865
|
+
activeSessionId = await backend.ensureSessionId(user, null, activeModel);
|
|
866
|
+
setSessionId(activeSessionId);
|
|
867
|
+
}
|
|
868
|
+
const nextChatMessages = [...chatMessages, { role: "user", content: text }];
|
|
869
|
+
setChatMessages(nextChatMessages);
|
|
870
|
+
pushMessage("user", text);
|
|
871
|
+
setThinking(true);
|
|
872
|
+
setStreamingText("");
|
|
873
|
+
try {
|
|
874
|
+
// Scan the local workspace so the backend VirtualFS is populated.
|
|
875
|
+
// Merge in any files created/edited during this session so edits
|
|
876
|
+
// persist across agent turns within the same chat.
|
|
877
|
+
const { workspaceTree, workspaceFiles: scannedFiles } = scanWorkspace(workspaceRoot);
|
|
878
|
+
const sessionFiles = Array.from(localFileCache.current.values());
|
|
879
|
+
const seenPaths = new Set(sessionFiles.map((f) => f.path));
|
|
880
|
+
const mergedFiles = [...sessionFiles, ...scannedFiles.filter((f) => !seenPaths.has(f.path))];
|
|
881
|
+
let assistant = "";
|
|
882
|
+
let localActionConfirmed = false;
|
|
883
|
+
for await (const event of backend.streamChat({
|
|
884
|
+
user,
|
|
885
|
+
sessionId: activeSessionId,
|
|
886
|
+
messages: nextChatMessages,
|
|
887
|
+
workspaceRoot,
|
|
888
|
+
workspaceTree,
|
|
889
|
+
workspaceFiles: mergedFiles,
|
|
890
|
+
model: activeModel
|
|
891
|
+
})) {
|
|
892
|
+
if (event.type === "run_in_terminal") {
|
|
893
|
+
localActionConfirmed = true;
|
|
894
|
+
}
|
|
895
|
+
if (event.type === "tool_result") {
|
|
896
|
+
const payload = (event.data ?? {});
|
|
897
|
+
if (payload.local === true) {
|
|
898
|
+
localActionConfirmed = true;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const piece = await handleEvent(event, activeSessionId);
|
|
902
|
+
if (piece) {
|
|
903
|
+
assistant += piece;
|
|
904
|
+
setStreamingText(normalizeAssistantText(assistant));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
setStreamingText("");
|
|
908
|
+
if (assistant.trim()) {
|
|
909
|
+
const cleanedAssistant = normalizeAssistantText(assistant);
|
|
910
|
+
const guardedAssistant = !localActionConfirmed && looksLikeLocalFilesystemClaim(cleanedAssistant)
|
|
911
|
+
? `Remote result (not yet confirmed as local filesystem change): ${cleanedAssistant}`
|
|
912
|
+
: cleanedAssistant;
|
|
913
|
+
pushMessage("assistant", guardedAssistant);
|
|
914
|
+
if (voiceEnabled) {
|
|
915
|
+
speakText(guardedAssistant);
|
|
916
|
+
}
|
|
917
|
+
setChatMessages((prev) => [...prev, { role: "assistant", content: cleanedAssistant }]);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
setChatMessages((prev) => [...prev, { role: "assistant", content: assistant }]);
|
|
921
|
+
}
|
|
922
|
+
pushMessage("system", "");
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
pushMessage("error", `Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
926
|
+
}
|
|
927
|
+
finally {
|
|
928
|
+
setThinking(false);
|
|
929
|
+
}
|
|
930
|
+
}, [
|
|
931
|
+
activeModel,
|
|
932
|
+
backend,
|
|
933
|
+
chatMessages,
|
|
934
|
+
exit,
|
|
935
|
+
handleEvent,
|
|
936
|
+
localFileCache,
|
|
937
|
+
openHistory,
|
|
938
|
+
pushMessage,
|
|
939
|
+
sessionId,
|
|
940
|
+
startLiveVoice,
|
|
941
|
+
stopLiveVoice,
|
|
942
|
+
thinking,
|
|
943
|
+
user,
|
|
944
|
+
voiceEnabled,
|
|
945
|
+
voiceListening,
|
|
946
|
+
workspaceRoot
|
|
947
|
+
]);
|
|
948
|
+
if (!trustedWorkspace) {
|
|
949
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#c0c9db", children: "claude" }), _jsx(Text, { color: "#8ea1bd", children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#f0f4ff", children: "Do you trust the files in this folder?" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#c8d5f0", children: workspaceRoot }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#8ea1bd", children: "Astra Code may read, write, or execute files contained in this directory. This can pose security risks, so only use files from trusted sources." }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#7aa2ff", children: "Learn more" }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: trustSelection === 0 ? "#f0f4ff" : "#8ea1bd", children: [trustSelection === 0 ? "❯ " : " ", "1. Yes, proceed"] }), _jsxs(Text, { color: trustSelection === 1 ? "#f0f4ff" : "#8ea1bd", children: [trustSelection === 1 ? "❯ " : " ", "2. No, exit"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "#8ea1bd", children: "Enter to confirm \u00B7 Esc to cancel" }) })] }));
|
|
950
|
+
}
|
|
951
|
+
if (booting) {
|
|
952
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: ASTRA_ASCII }), _jsx(Text, { color: "#8ea1bd", children: FOUNDER_WELCOME }), _jsxs(Text, { color: "#8aa2c9", children: [_jsx(Spinner, { type: "dots12" }), " Booting Astra terminal shell..."] })] }));
|
|
953
|
+
}
|
|
954
|
+
if (bootError) {
|
|
955
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: ASTRA_ASCII }), _jsx(Text, { color: "#8ea1bd", children: FOUNDER_WELCOME }), _jsx(Text, { color: "red", children: bootError })] }));
|
|
956
|
+
}
|
|
957
|
+
if (!user) {
|
|
958
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: ASTRA_ASCII }), _jsx(Text, { color: "#8ea1bd", children: FOUNDER_WELCOME }), _jsxs(Text, { color: "#b8c8ff", children: ["Astra terminal AI pair programmer (", loginMode === "login" ? "Sign in" : "Create account", ")"] }), _jsx(Text, { color: "#7c8ea8", children: "Press Ctrl+T to toggle Sign in / Create account" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "#93a3b8", children: "Email: " }), _jsx(TextInput, { value: email, onChange: setEmail, focus: loginField === "email", onSubmit: () => {
|
|
959
|
+
setLoginField("password");
|
|
960
|
+
} })] }), _jsxs(Box, { children: [_jsx(Text, { color: "#93a3b8", children: "Password: " }), _jsx(TextInput, { value: password, onChange: setPassword, mask: "*", focus: loginField === "password", onSubmit: () => {
|
|
961
|
+
void doAuth();
|
|
962
|
+
} })] }), authBusy ? (_jsxs(Text, { color: "#a5d6ff", children: [_jsx(Spinner, { type: "aesthetic" }), " Syncing star map..."] })) : (_jsx(Text, { color: "#7c8ea8", children: "Enter email, press Enter, then submit password." })), loginError ? _jsx(Text, { color: "red", children: loginError }) : null] }));
|
|
963
|
+
}
|
|
964
|
+
if (historyOpen) {
|
|
965
|
+
const selected = filteredHistory[historyIndex];
|
|
966
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: ASTRA_ASCII }), _jsx(Text, { color: "#8ea1bd", children: FOUNDER_WELCOME }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), historyMode === "picker" ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: "#dce9ff", children: "History Picker" }), _jsx(Text, { color: "#5a7a9a", children: "Esc close \u00B7 Enter select" })] }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { color: historyPickerIndex === 0 ? "#dce9ff" : "#7a9bba", children: [historyPickerIndex === 0 ? "❯ " : " ", "View chat history"] }) }), _jsx(Box, { marginTop: 1, flexDirection: "row", children: _jsxs(Text, { color: historyPickerIndex === 1 ? "#dce9ff" : "#7a9bba", children: [historyPickerIndex === 1 ? "❯ " : " ", "View credit usage history"] }) })] }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), _jsxs(Text, { color: "#5a7a9a", children: ["Credit usage history opens: ", HISTORY_SETTINGS_URL] })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: "#dce9ff", children: "Chat History" }), _jsx(Text, { color: "#5a7a9a", children: "Esc back \u00B7 Enter open \u00B7 D delete \u00B7 \u2190/\u2192 page" })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "search " }), _jsx(TextInput, { value: historyQuery, onChange: setHistoryQuery, placeholder: "Filter chats..." })] }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), historyLoading ? (_jsxs(Text, { color: "#8aa2c9", children: [_jsx(Spinner, { type: "dots12" }), " Loading chat history..."] })) : filteredHistory.length === 0 ? (_jsx(Text, { color: "#8ea1bd", children: "No sessions found." })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, children: pageRows.map((row, localIdx) => {
|
|
967
|
+
const idx = pageStart + localIdx;
|
|
968
|
+
const active = idx === historyIndex;
|
|
969
|
+
return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { color: active ? "#dce9ff" : "#7a9bba", children: [active ? "❯ " : " ", (row.title || "Untitled").slice(0, 58).padEnd(60, " ")] }), _jsxs(Text, { color: "#5a7a9a", children: [String(row.total_messages ?? 0).padStart(3, " "), " msgs \u00B7 ", formatSessionDate(row.updated_at)] })] }, row.id));
|
|
970
|
+
}) })), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { color: "#5a7a9a", children: ["Page ", historyPage + 1, " / ", historyPageCount] }), _jsxs(Text, { color: "#5a7a9a", children: ["Selected: ", selected ? selected.id : "--"] })] })] }))] }));
|
|
971
|
+
}
|
|
972
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#7aa2ff", children: ASTRA_ASCII }), _jsx(Text, { color: "#8ea1bd", children: FOUNDER_WELCOME }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), _jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "mode " }), _jsx(Text, { color: "#9ad5ff", children: runtimeMode })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "scope " }), _jsx(Text, { color: "#7a9bba", children: workspaceRoot })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "provider " }), _jsx(Text, { color: "#9ad5ff", children: getProviderForModel(activeModel) })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "credits " }), _jsxs(Text, { color: creditsRemaining !== null && creditsRemaining < 50 ? "#ffaa55" : "#9ad5ff", children: [creditsRemaining ?? "--", lastCreditCost !== null ? (_jsxs(Text, { color: "#5a7a9a", children: [" (-", lastCreditCost, ")"] })) : null] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "model " }), _jsx(Text, { color: "#9ad5ff", children: activeModel })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#4a6070", children: "voice " }), _jsx(Text, { color: voiceEnabled ? "#9ad5ff" : "#5a7a9a", children: voiceEnabled ? (voiceListening ? "on/listening" : "on") : "off" })] })] }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), _jsx(Text, { color: "#3a5068", children: "/help /new /history /voice on|off|status|start|stop|input /settings /logout /exit" }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [messages.map((message, index) => {
|
|
973
|
+
const style = styleForKind(message.kind);
|
|
974
|
+
const paddedLabel = style.label.padEnd(LABEL_WIDTH, " ");
|
|
975
|
+
const isSpacing = message.text === "" && message.kind === "system";
|
|
976
|
+
if (isSpacing) {
|
|
977
|
+
return _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: " " }) }, `${index}-spacer`);
|
|
978
|
+
}
|
|
979
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: style.labelColor, bold: style.bold, children: paddedLabel }), message.kind === "assistant" ? (_jsx(Box, { flexDirection: "column", children: renderMarkdownContent(message.text, style.textColor, `assistant-${index}`) })) : (_jsx(Text, { color: style.textColor, bold: style.bold && message.kind === "error", children: message.text }))] }, `${index}-${message.kind}`));
|
|
980
|
+
}), streamingText ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: styleForKind("assistant").label.padEnd(LABEL_WIDTH, " ") }), _jsx(Box, { flexDirection: "column", children: renderMarkdownContent(streamingText, "#dce9ff", "streaming") })] })) : null] }), _jsx(Text, { color: "#2a3a50", children: DIVIDER }), thinking ? (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Text, { color: "#7aa2ff", children: "◆ astra".padEnd(LABEL_WIDTH, " ") }), _jsxs(Text, { color: "#6080a0", children: [_jsx(Spinner, { type: "dots2" }), _jsx(Text, { color: "#8aa2c9", children: " thinking..." })] })] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "#7aa2ff", children: "\u276F " }), _jsx(TextInput, { value: prompt, onChange: setPrompt, onSubmit: (value) => {
|
|
981
|
+
setPrompt("");
|
|
982
|
+
void sendPrompt(value);
|
|
983
|
+
}, placeholder: voiceEnabled ? "Ask Astra... (/voice start for live transcription)" : "Ask Astra..." })] })] }));
|
|
984
|
+
};
|
|
985
|
+
//# sourceMappingURL=App.js.map
|