neagen 0.1.0 → 0.1.2
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/lib/ansi.mjs +2 -0
- package/lib/api.mjs +31 -3
- package/lib/device-login.mjs +6 -4
- package/lib/eve-chat.mjs +224 -0
- package/lib/eve-product-tui.mjs +399 -0
- package/lib/picker.mjs +280 -0
- package/lib/slash.mjs +49 -0
- package/lib/tui.mjs +226 -88
- package/lib/turn-stream.mjs +219 -0
- package/neagen.mjs +2 -2
- package/package.json +1 -1
package/lib/slash.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Slash commands for the neagen TUI — single source for /help and command selection.
|
|
2
|
+
|
|
3
|
+
export const SLASH_COMMANDS = [
|
|
4
|
+
{ cmd: "/model", help: "pick a model — type to filter, arrows to move, enter to select" },
|
|
5
|
+
{ cmd: "/new", help: "start a fresh conversation" },
|
|
6
|
+
{ cmd: "/help", help: "show commands" },
|
|
7
|
+
{ cmd: "/exit", help: "quit (also /quit, Ctrl-D, Ctrl-C)" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/** Hidden alias — completable, not listed in /help. */
|
|
11
|
+
export const SLASH_ALIASES = ["/quit"];
|
|
12
|
+
|
|
13
|
+
export const SLASH_NAMES = [...SLASH_COMMANDS.map((entry) => entry.cmd), ...SLASH_ALIASES];
|
|
14
|
+
|
|
15
|
+
export function slashQuery(line) {
|
|
16
|
+
if (!line.startsWith("/") || line.includes(" ")) return null;
|
|
17
|
+
return line.toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function slashCommandMatches(line, commands = SLASH_COMMANDS) {
|
|
21
|
+
const query = slashQuery(line);
|
|
22
|
+
if (query === null) return [];
|
|
23
|
+
return commands.filter((entry) => entry.cmd.toLowerCase().startsWith(query));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function slashCompletionSuffix(line) {
|
|
27
|
+
const query = slashQuery(line);
|
|
28
|
+
if (query === null) return "";
|
|
29
|
+
const hits = SLASH_NAMES.filter((name) => name.toLowerCase().startsWith(query));
|
|
30
|
+
if (hits.length !== 1) return "";
|
|
31
|
+
return hits[0].slice(line.length);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* readline completer: Tab-completes slash commands on the command word only
|
|
36
|
+
* (leading "/" with no space yet) so Tab never hijacks normal chat input.
|
|
37
|
+
*/
|
|
38
|
+
export function completeSlash(line) {
|
|
39
|
+
if (!line.startsWith("/") || line.includes(" ")) return [[], line];
|
|
40
|
+
const query = line.toLowerCase();
|
|
41
|
+
const hits = SLASH_NAMES.filter((name) => name.startsWith(query));
|
|
42
|
+
return [hits, line];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function slashHelpLines() {
|
|
46
|
+
const rows = SLASH_COMMANDS.map(({ cmd, help }) => ` ${cmd.padEnd(11)} ${help}`);
|
|
47
|
+
rows.push(" (type / to open the command selector; Tab accepts the prefill)");
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
package/lib/tui.mjs
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
// neagen
|
|
2
|
-
// Login happens here (device
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// this package (see ../package.json), pinned to an exact version because these
|
|
8
|
-
// dist paths are internal to that version.
|
|
9
|
-
|
|
10
|
-
import { createRequire } from "node:module";
|
|
11
|
-
import { dirname, join } from "node:path";
|
|
12
|
-
import { pathToFileURL } from "node:url";
|
|
13
|
-
import { Client } from "eve/client";
|
|
1
|
+
// neagen CLI — chat through Neagen's server-mediated turn transport.
|
|
2
|
+
// Login happens here (device grant) when no valid session exists.
|
|
3
|
+
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { stdin } from "node:process";
|
|
6
|
+
import { dim, bold } from "./ansi.mjs";
|
|
14
7
|
import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
|
|
15
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getSelectedModel,
|
|
10
|
+
listModels,
|
|
11
|
+
probeHost,
|
|
12
|
+
resolveIdentity,
|
|
13
|
+
selectModel,
|
|
14
|
+
} from "./api.mjs";
|
|
16
15
|
import { runDeviceGrantLogin } from "./device-login.mjs";
|
|
17
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
formatRequestDetails,
|
|
18
|
+
requestToolName,
|
|
19
|
+
sendTransportTurn,
|
|
20
|
+
sendWakeSyncTurn,
|
|
21
|
+
summarizeResult,
|
|
22
|
+
} from "./eve-chat.mjs";
|
|
23
|
+
import { createTurnStreamRenderer } from "./turn-stream.mjs";
|
|
24
|
+
import { pickModel, readPromptLine } from "./picker.mjs";
|
|
25
|
+
import { SLASH_COMMANDS, slashHelpLines } from "./slash.mjs";
|
|
18
26
|
import { subscribeInboxWakeAbly } from "./wake.mjs";
|
|
19
27
|
|
|
20
|
-
const require = createRequire(import.meta.url);
|
|
21
|
-
// `eve` exposes "./package.json" in its exports map, so this resolves the
|
|
22
|
-
// installed package root regardless of hoisting.
|
|
23
|
-
const EVE_DIST = join(dirname(require.resolve("eve/package.json")), "dist", "src");
|
|
24
|
-
const EVE_TUI = join(EVE_DIST, "cli", "dev", "tui");
|
|
25
|
-
|
|
26
28
|
const profile = process.env.NEAGEN_PROFILE?.trim() || undefined;
|
|
27
29
|
const host = resolveHost();
|
|
28
30
|
|
|
@@ -38,59 +40,21 @@ try {
|
|
|
38
40
|
process.exit(1);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
async function importEve(relPath) {
|
|
42
|
-
return import(pathToFileURL(join(EVE_DIST, relPath)).href);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const [
|
|
46
|
-
{ EveTUIRunner },
|
|
47
|
-
{ createPromptCommandHandler },
|
|
48
|
-
{ promptCommandsFor },
|
|
49
|
-
{ formatRemoteAuthChallengeMessage },
|
|
50
|
-
{ toErrorMessage },
|
|
51
|
-
{ isVercelAuthChallenge },
|
|
52
|
-
{ TerminalRenderer },
|
|
53
|
-
] = await Promise.all([
|
|
54
|
-
import(pathToFileURL(join(EVE_TUI, "runner.js")).href),
|
|
55
|
-
import(pathToFileURL(join(EVE_TUI, "prompt-command-handler.js")).href),
|
|
56
|
-
import(pathToFileURL(join(EVE_TUI, "prompt-commands.js")).href),
|
|
57
|
-
import(pathToFileURL(join(EVE_TUI, "remote-auth-result.js")).href),
|
|
58
|
-
importEve("shared/errors.js"),
|
|
59
|
-
importEve("services/dev-client/vercel-auth-error.js"),
|
|
60
|
-
import(pathToFileURL(join(EVE_TUI, "terminal-renderer.js")).href),
|
|
61
|
-
]);
|
|
62
|
-
|
|
63
|
-
const target = {
|
|
64
|
-
kind: "remote",
|
|
65
|
-
serverUrl: host,
|
|
66
|
-
workspaceRoot: process.cwd(),
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const renderer = new TerminalRenderer({ availablePromptCommands: promptCommandsFor("remote") });
|
|
70
|
-
|
|
71
43
|
function fail(message) {
|
|
72
|
-
|
|
73
|
-
panel(renderer).renderLine(message, "error");
|
|
74
|
-
} catch {
|
|
75
|
-
console.error(`\n ${message}\n`);
|
|
76
|
-
}
|
|
44
|
+
console.error(`\n ${message}\n`);
|
|
77
45
|
process.exit(1);
|
|
78
46
|
}
|
|
79
47
|
|
|
80
48
|
async function ensureReachable() {
|
|
81
|
-
|
|
82
|
-
ui.begin("neagen", "pulse");
|
|
83
|
-
ui.renderLine(`Connecting to ${host}…`, "info");
|
|
49
|
+
process.stdout.write(`Connecting to ${host}...\n`);
|
|
84
50
|
try {
|
|
85
51
|
await probeHost(host);
|
|
86
|
-
|
|
52
|
+
process.stdout.write("Agent online.\n");
|
|
87
53
|
} catch (e) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ui.end({ preserveDiagnostics: true });
|
|
54
|
+
process.stderr.write(`Can't reach ${host} (${e.message}).\n`);
|
|
55
|
+
process.stderr.write("Check your connection, or set NEAGEN_HOST to your server.\n");
|
|
91
56
|
process.exit(1);
|
|
92
57
|
}
|
|
93
|
-
ui.end({ preserveDiagnostics: false });
|
|
94
58
|
}
|
|
95
59
|
|
|
96
60
|
async function ensureAuthenticated() {
|
|
@@ -101,7 +65,7 @@ async function ensureAuthenticated() {
|
|
|
101
65
|
clearCreds(profile);
|
|
102
66
|
token = null;
|
|
103
67
|
}
|
|
104
|
-
token = await runDeviceGrantLogin({ host, profile
|
|
68
|
+
token = await runDeviceGrantLogin({ host, profile });
|
|
105
69
|
const me = await resolveIdentity(host, token);
|
|
106
70
|
if (!me) {
|
|
107
71
|
clearCreds(profile);
|
|
@@ -110,6 +74,107 @@ async function ensureAuthenticated() {
|
|
|
110
74
|
return { token, me };
|
|
111
75
|
}
|
|
112
76
|
|
|
77
|
+
function formatModel(model) {
|
|
78
|
+
if (!model) return "unknown";
|
|
79
|
+
const label = model.providerLabel && model.name ? `${model.providerLabel} ${model.name}` : model.id;
|
|
80
|
+
return `${label} (${model.id})`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function modelAliases(input) {
|
|
84
|
+
const value = input.trim();
|
|
85
|
+
const lower = value.toLowerCase();
|
|
86
|
+
if (lower === "deepseek" || lower === "flash") return "deepseek/deepseek-v4-flash";
|
|
87
|
+
if (lower === "sonnet" || lower === "claude" || lower === "claude-sonnet") {
|
|
88
|
+
return "anthropic/claude-sonnet-4.6";
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runModelPicker(rl, token) {
|
|
94
|
+
let selection;
|
|
95
|
+
let models;
|
|
96
|
+
try {
|
|
97
|
+
[selection, models] = await Promise.all([
|
|
98
|
+
getSelectedModel(host, token),
|
|
99
|
+
listModels(host, token),
|
|
100
|
+
]);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
process.stderr.write(`! ${e?.message ?? e}\n`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const activeId = selection?.activeModel?.id;
|
|
107
|
+
const chosen = await pickModel(rl, models, activeId);
|
|
108
|
+
if (!chosen) {
|
|
109
|
+
process.stdout.write(`${dim("(no change)")}\n`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (chosen.id === activeId) {
|
|
113
|
+
process.stdout.write(`${dim(`already using ${formatModel(chosen)}`)}\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const updated = await selectModel(host, token, chosen.id);
|
|
119
|
+
process.stdout.write(`Switched model to ${bold(formatModel(updated.activeModel))}.\n`);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
process.stderr.write(`! ${e?.message ?? e}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function switchModelByName(token, raw) {
|
|
126
|
+
const modelId = modelAliases(raw);
|
|
127
|
+
const selection = await selectModel(host, token, modelId);
|
|
128
|
+
process.stdout.write(`Switched model to ${bold(formatModel(selection.activeModel))}.\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function handleModelCommand(rl, token, raw) {
|
|
132
|
+
const arg = raw.trim();
|
|
133
|
+
if (!arg) {
|
|
134
|
+
await runModelPicker(rl, token);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await switchModelByName(token, arg);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function printHelp() {
|
|
141
|
+
process.stdout.write([...slashHelpLines(), ""].join("\n"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeSlashInput(input) {
|
|
145
|
+
if (input === "/quit") return "/exit";
|
|
146
|
+
return input;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function askApproval(rl, request) {
|
|
150
|
+
const tool = requestToolName(request);
|
|
151
|
+
const details = formatRequestDetails(request);
|
|
152
|
+
const answer = (await rl.question(
|
|
153
|
+
`\nApprove ${tool}${details ? ` (${details})` : ""}?\n(y/n) > `,
|
|
154
|
+
)).trim().toLowerCase();
|
|
155
|
+
return answer === "y" || answer === "yes";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function askQuestion(rl, request) {
|
|
159
|
+
const options = (request.options ?? []).map((option, index) => `${index + 1}. ${option.label}`).join(" ");
|
|
160
|
+
return rl.question(`\n${request.prompt}\n${options ? `${options}\n` : ""}> `);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const turnRenderer = createTurnStreamRenderer();
|
|
164
|
+
|
|
165
|
+
function printTurnOutcome(result) {
|
|
166
|
+
if (result?.assistantTextStreamed) {
|
|
167
|
+
process.stdout.write("\n");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const summary = summarizeResult(result);
|
|
171
|
+
if (summary && summary !== "(no reply)") {
|
|
172
|
+
process.stdout.write(`\n${summary}\n\n`);
|
|
173
|
+
} else {
|
|
174
|
+
process.stdout.write("\n");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
113
178
|
await ensureReachable();
|
|
114
179
|
|
|
115
180
|
let session;
|
|
@@ -122,27 +187,58 @@ try {
|
|
|
122
187
|
const { me } = session;
|
|
123
188
|
const label = me.displayName ?? "you";
|
|
124
189
|
const ownerId = me.ownerId;
|
|
190
|
+
const token = session.token;
|
|
125
191
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
192
|
+
let conversationId;
|
|
193
|
+
const rl = readline.createInterface({
|
|
194
|
+
input: stdin,
|
|
195
|
+
output: process.stdout,
|
|
196
|
+
historySize: 100,
|
|
130
197
|
});
|
|
131
198
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
199
|
+
let chain = Promise.resolve();
|
|
200
|
+
function runExclusive(fn) {
|
|
201
|
+
const next = chain.then(fn, fn);
|
|
202
|
+
chain = next.catch(() => {});
|
|
203
|
+
return next;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function sendTurn(message) {
|
|
207
|
+
const result = await sendTransportTurn({
|
|
208
|
+
host,
|
|
209
|
+
token,
|
|
210
|
+
conversationId,
|
|
211
|
+
message,
|
|
212
|
+
render: turnRenderer,
|
|
213
|
+
handlers: {
|
|
214
|
+
askApproval: (request) => askApproval(rl, request),
|
|
215
|
+
askQuestion: (request) => askQuestion(rl, request),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
conversationId = result?.conversationId ?? conversationId;
|
|
219
|
+
return result;
|
|
136
220
|
}
|
|
137
221
|
|
|
138
222
|
let wake;
|
|
139
223
|
try {
|
|
140
|
-
const token = loadToken(profile);
|
|
141
224
|
wake = await subscribeInboxWakeAbly(ownerId, (ev) => {
|
|
142
225
|
const what = ev.kind === "message" ? "a message" : `a file (${ev.filename})`;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
226
|
+
process.stdout.write(`\nNew mail: ${what} arrived. Fetching it now...\n`);
|
|
227
|
+
void runExclusive(async () => {
|
|
228
|
+
const result = await sendWakeSyncTurn({
|
|
229
|
+
host,
|
|
230
|
+
token,
|
|
231
|
+
event: ev,
|
|
232
|
+
render: turnRenderer,
|
|
233
|
+
handlers: {
|
|
234
|
+
askApproval: (request) => askApproval(rl, request),
|
|
235
|
+
askQuestion: (request) => askQuestion(rl, request),
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
printTurnOutcome(result);
|
|
239
|
+
}).catch((e) => {
|
|
240
|
+
process.stderr.write(`! wake sync failed: ${e?.message ?? e}\n`);
|
|
241
|
+
});
|
|
146
242
|
}, { host, bearerToken: token });
|
|
147
243
|
} catch {
|
|
148
244
|
/* wake is best-effort */
|
|
@@ -158,16 +254,58 @@ const stopWake = async () => {
|
|
|
158
254
|
process.on("SIGINT", stopWake);
|
|
159
255
|
process.on("exit", () => void wake?.close());
|
|
160
256
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
257
|
+
process.stdout.write(`neagen (${label})\n`);
|
|
258
|
+
process.stdout.write("Type a message, / for commands, /model to switch models, /help for the list.\n\n");
|
|
259
|
+
|
|
260
|
+
async function handleSlash(rl, input) {
|
|
261
|
+
const normalized = normalizeSlashInput(input);
|
|
262
|
+
|
|
263
|
+
if (normalized === "/exit") return true;
|
|
264
|
+
if (normalized === "/help") {
|
|
265
|
+
printHelp();
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (normalized === "/new") {
|
|
269
|
+
conversationId = undefined;
|
|
270
|
+
process.stdout.write("Started a new conversation.\n");
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
if (normalized === "/model" || normalized.startsWith("/model ")) {
|
|
274
|
+
await handleModelCommand(rl, token, normalized.slice("/model".length));
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
process.stdout.write(`${dim(`unknown command: ${input} (try /help)`)}\n`);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
for (;;) {
|
|
284
|
+
let line;
|
|
285
|
+
try {
|
|
286
|
+
line = await readPromptLine(rl, { prompt: "neagen> ", slashCommands: SLASH_COMMANDS });
|
|
287
|
+
} catch {
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
if (line === null) break;
|
|
291
|
+
|
|
292
|
+
const input = line.trim();
|
|
293
|
+
if (!input) continue;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
if (input.startsWith("/")) {
|
|
297
|
+
if (await handleSlash(rl, input)) break;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = await runExclusive(() => sendTurn(line));
|
|
302
|
+
printTurnOutcome(result);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
process.stderr.write(`! ${e?.message ?? e}\n`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
rl.close();
|
|
309
|
+
}
|
|
172
310
|
|
|
173
311
|
await stopWake();
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { clearLine, cursorTo } from "node:readline";
|
|
2
|
+
import { stdout } from "node:process";
|
|
3
|
+
import { dim } from "./ansi.mjs";
|
|
4
|
+
|
|
5
|
+
function sseEvent(event, data) {
|
|
6
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseSseChunk(buffer) {
|
|
10
|
+
const events = [];
|
|
11
|
+
const parts = buffer.split(/\n\n+/);
|
|
12
|
+
const trailing = parts.pop() ?? "";
|
|
13
|
+
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
const event = part.match(/^event: (.+)$/m)?.[1];
|
|
16
|
+
const dataLine = part.match(/^data: (.+)$/m)?.[1];
|
|
17
|
+
if (!event || !dataLine) continue;
|
|
18
|
+
try {
|
|
19
|
+
events.push({ event, data: JSON.parse(dataLine) });
|
|
20
|
+
} catch {
|
|
21
|
+
/* ignore malformed chunks */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { events, trailing };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readReasoningDelta(data) {
|
|
29
|
+
if (typeof data?.reasoningDelta === "string") return data.reasoningDelta;
|
|
30
|
+
if (typeof data?.delta === "string") return data.delta;
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readMessageDelta(data) {
|
|
35
|
+
if (typeof data?.messageDelta === "string") return data.messageDelta;
|
|
36
|
+
if (typeof data?.delta === "string") return data.delta;
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createTurnStreamRenderer({ write = (text) => stdout.write(text) } = {}) {
|
|
41
|
+
let assistantLineOpen = false;
|
|
42
|
+
let reasoningLineOpen = false;
|
|
43
|
+
let assistantTextStreamed = false;
|
|
44
|
+
|
|
45
|
+
const closeAssistantLine = () => {
|
|
46
|
+
if (!assistantLineOpen) return;
|
|
47
|
+
write("\n");
|
|
48
|
+
assistantLineOpen = false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const closeReasoningLine = () => {
|
|
52
|
+
if (!reasoningLineOpen) return;
|
|
53
|
+
write("\n");
|
|
54
|
+
reasoningLineOpen = false;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
get assistantTextStreamed() {
|
|
59
|
+
return assistantTextStreamed;
|
|
60
|
+
},
|
|
61
|
+
reset() {
|
|
62
|
+
assistantTextStreamed = false;
|
|
63
|
+
closeAssistantLine();
|
|
64
|
+
closeReasoningLine();
|
|
65
|
+
},
|
|
66
|
+
handle(eventName, data) {
|
|
67
|
+
switch (eventName) {
|
|
68
|
+
case "turn.started":
|
|
69
|
+
closeAssistantLine();
|
|
70
|
+
closeReasoningLine();
|
|
71
|
+
break;
|
|
72
|
+
case "reasoning.appended": {
|
|
73
|
+
const delta = readReasoningDelta(data);
|
|
74
|
+
if (!delta) break;
|
|
75
|
+
if (!reasoningLineOpen) {
|
|
76
|
+
closeAssistantLine();
|
|
77
|
+
write(`${dim("… ")}`);
|
|
78
|
+
reasoningLineOpen = true;
|
|
79
|
+
}
|
|
80
|
+
write(dim(delta));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "reasoning.completed":
|
|
84
|
+
closeReasoningLine();
|
|
85
|
+
break;
|
|
86
|
+
case "message.appended": {
|
|
87
|
+
const delta = readMessageDelta(data);
|
|
88
|
+
if (!delta) break;
|
|
89
|
+
assistantTextStreamed = true;
|
|
90
|
+
closeReasoningLine();
|
|
91
|
+
if (!assistantLineOpen) {
|
|
92
|
+
write("\n");
|
|
93
|
+
assistantLineOpen = true;
|
|
94
|
+
}
|
|
95
|
+
write(delta);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "message.completed":
|
|
99
|
+
closeAssistantLine();
|
|
100
|
+
break;
|
|
101
|
+
case "actions.requested": {
|
|
102
|
+
closeAssistantLine();
|
|
103
|
+
closeReasoningLine();
|
|
104
|
+
const actions = Array.isArray(data?.actions) ? data.actions : [];
|
|
105
|
+
const labels = actions.map((action) => {
|
|
106
|
+
if (action?.kind === "tool-call" && typeof action.toolName === "string") {
|
|
107
|
+
return action.toolName;
|
|
108
|
+
}
|
|
109
|
+
return action?.kind ?? "action";
|
|
110
|
+
});
|
|
111
|
+
if (labels.length > 0) {
|
|
112
|
+
write(`${dim(`↳ ${labels.join(", ")}`)}\n`);
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "action.result": {
|
|
117
|
+
const result = data?.result;
|
|
118
|
+
const name = result?.toolName ?? result?.kind ?? "tool";
|
|
119
|
+
const status = data?.status ?? "completed";
|
|
120
|
+
write(`${dim(` ${status === "completed" ? "✓" : "×"} ${name}`)}\n`);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "input.requested":
|
|
124
|
+
closeAssistantLine();
|
|
125
|
+
closeReasoningLine();
|
|
126
|
+
write(`${dim("… approval required")}\n`);
|
|
127
|
+
break;
|
|
128
|
+
case "neagen.waiting_saved":
|
|
129
|
+
closeAssistantLine();
|
|
130
|
+
closeReasoningLine();
|
|
131
|
+
break;
|
|
132
|
+
case "neagen.error":
|
|
133
|
+
closeAssistantLine();
|
|
134
|
+
closeReasoningLine();
|
|
135
|
+
write(`\n! ${data?.message ?? "Turn failed."}\n`);
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
finish() {
|
|
142
|
+
closeAssistantLine();
|
|
143
|
+
closeReasoningLine();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createTurnStatusSpinner(label = "thinking") {
|
|
149
|
+
if (!stdout.isTTY) return { stop() {} };
|
|
150
|
+
|
|
151
|
+
let frame = 0;
|
|
152
|
+
let active = true;
|
|
153
|
+
const frames = ["", ".", "..", "..."];
|
|
154
|
+
const render = () => {
|
|
155
|
+
if (!active) return;
|
|
156
|
+
cursorTo(stdout, 0);
|
|
157
|
+
stdout.write(`${label}${frames[frame % frames.length]}`);
|
|
158
|
+
clearLine(stdout, 1);
|
|
159
|
+
frame += 1;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
render();
|
|
163
|
+
const timer = setInterval(render, 450);
|
|
164
|
+
return {
|
|
165
|
+
stop() {
|
|
166
|
+
if (!active) return;
|
|
167
|
+
active = false;
|
|
168
|
+
clearInterval(timer);
|
|
169
|
+
cursorTo(stdout, 0);
|
|
170
|
+
clearLine(stdout, 0);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function readTurnSseStream(response, onEvent) {
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
let message = `HTTP ${response.status}`;
|
|
178
|
+
const text = await response.text();
|
|
179
|
+
try {
|
|
180
|
+
message = JSON.parse(text)?.error?.message ?? message;
|
|
181
|
+
} catch {
|
|
182
|
+
if (text.trim()) message = text.trim();
|
|
183
|
+
}
|
|
184
|
+
throw new Error(message);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reader = response.body?.getReader();
|
|
188
|
+
if (!reader) {
|
|
189
|
+
throw new Error("Turn stream is not readable.");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const decoder = new TextDecoder();
|
|
193
|
+
let buffer = "";
|
|
194
|
+
const collected = [];
|
|
195
|
+
|
|
196
|
+
while (true) {
|
|
197
|
+
const { done, value } = await reader.read();
|
|
198
|
+
if (done) break;
|
|
199
|
+
buffer += decoder.decode(value, { stream: true });
|
|
200
|
+
const parsed = parseSseChunk(buffer);
|
|
201
|
+
buffer = parsed.trailing;
|
|
202
|
+
for (const entry of parsed.events) {
|
|
203
|
+
collected.push(entry);
|
|
204
|
+
await onEvent?.(entry.event, entry.data);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (buffer.trim()) {
|
|
209
|
+
const parsed = parseSseChunk(`${buffer}\n\n`);
|
|
210
|
+
for (const entry of parsed.events) {
|
|
211
|
+
collected.push(entry);
|
|
212
|
+
await onEvent?.(entry.event, entry.data);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return collected;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { sseEvent };
|
package/neagen.mjs
CHANGED
|
@@ -62,8 +62,8 @@ async function runTui(profile) {
|
|
|
62
62
|
);
|
|
63
63
|
process.exit(1);
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
await
|
|
65
|
+
const { runProductTui } = await import("./lib/eve-product-tui.mjs");
|
|
66
|
+
await runProductTui({ profile });
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
async function runLogin(profile) {
|
package/package.json
CHANGED