neagen 0.1.1 → 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/eve-chat.mjs CHANGED
@@ -1,4 +1,9 @@
1
1
  import { Client } from "eve/client";
2
+ import {
3
+ createTurnStatusSpinner,
4
+ createTurnStreamRenderer,
5
+ readTurnSseStream,
6
+ } from "./turn-stream.mjs";
2
7
 
3
8
  export function createEveClient(host, token) {
4
9
  return new Client({
@@ -107,19 +112,26 @@ export async function sendEveTurn(session, message, options = {}) {
107
112
  return options.settle ? options.settle(result, session) : result;
108
113
  }
109
114
 
110
- function parseSseEvents(text) {
111
- return text
112
- .split(/\n\n+/)
113
- .map((chunk) => {
114
- const event = chunk.match(/^event: (.+)$/m)?.[1];
115
- const data = chunk.match(/^data: (.+)$/m)?.[1];
116
- if (!event || !data) return undefined;
117
- return { event, data: JSON.parse(data) };
118
- })
119
- .filter(Boolean);
115
+ function resolveTurnPayload(events) {
116
+ const finalized = events.find((entry) => entry.event === "neagen.finalized")?.data;
117
+ const waiting = events.find((entry) => entry.event === "neagen.waiting_saved")?.data;
118
+ const declined = events.find((entry) => entry.event === "neagen.gate_declined")?.data;
119
+ const legacyTurn = events.find((entry) => entry.event === "turn")?.data;
120
+
121
+ if (waiting) {
122
+ return {
123
+ conversationId: waiting.conversationId,
124
+ runtimeStatus: waiting.runtimeStatus,
125
+ inputRequests: waiting.inputRequests,
126
+ };
127
+ }
128
+ if (finalized) return finalized;
129
+ if (declined) return declined;
130
+ if (legacyTurn) return legacyTurn;
131
+ throw new Error("Turn stream ended without a Neagen result.");
120
132
  }
121
133
 
122
- async function postTurn(host, token, body) {
134
+ async function postTurnStream(host, token, body, { render } = {}) {
123
135
  const res = await fetch(`${host.replace(/\/$/, "")}/api/turns`, {
124
136
  method: "POST",
125
137
  headers: {
@@ -130,41 +142,83 @@ async function postTurn(host, token, body) {
130
142
  body: JSON.stringify(body),
131
143
  signal: AbortSignal.timeout(120_000),
132
144
  });
133
- const text = await res.text();
134
- if (!res.ok) {
135
- let message = `HTTP ${res.status}`;
136
- try {
137
- message = JSON.parse(text)?.error?.message ?? message;
138
- } catch {
139
- if (text.trim()) message = text.trim();
140
- }
141
- throw new Error(message);
145
+
146
+ const renderer = render ?? createTurnStreamRenderer();
147
+ const spinner = createTurnStatusSpinner("thinking");
148
+ let streamError;
149
+
150
+ try {
151
+ const events = await readTurnSseStream(res, (event, data) => {
152
+ spinner.stop();
153
+ if (event === "neagen.error") {
154
+ streamError = new Error(data?.message ?? "Turn failed.");
155
+ }
156
+ renderer.handle(event, data);
157
+ });
158
+ renderer.finish();
159
+
160
+ if (streamError) throw streamError;
161
+
162
+ return {
163
+ result: resolveTurnPayload(events),
164
+ assistantTextStreamed: renderer.assistantTextStreamed,
165
+ };
166
+ } finally {
167
+ spinner.stop();
142
168
  }
143
- return parseSseEvents(text).find((entry) => entry.event === "turn")?.data;
144
169
  }
145
170
 
146
- export async function sendWakeSyncTurn({ host, token, event, handlers }) {
171
+ export async function sendWakeSyncTurn({ host, token, event, handlers, render }) {
147
172
  return sendTransportTurn({
148
173
  host,
149
174
  token,
150
175
  conversationId: undefined,
151
176
  message: wakeSyncPrompt(event),
152
177
  handlers,
178
+ render,
153
179
  });
154
180
  }
155
181
 
156
- export async function sendTransportTurn({ host, token, conversationId, message, handlers }) {
157
- let result = await postTurn(host, token, { conversationId, body: message });
182
+ export async function sendTransportTurn({
183
+ host,
184
+ token,
185
+ conversationId,
186
+ message,
187
+ handlers,
188
+ render,
189
+ }) {
190
+ const renderer = render ?? createTurnStreamRenderer();
191
+ renderer.reset();
192
+ let assistantTextStreamed = false;
193
+
194
+ let leg = await postTurnStream(
195
+ host,
196
+ token,
197
+ { conversationId, body: message },
198
+ { render: renderer },
199
+ );
200
+ assistantTextStreamed ||= leg.assistantTextStreamed;
201
+ let result = leg.result;
202
+
158
203
  while ((result?.inputRequests?.length ?? 0) > 0) {
159
204
  const request = result.inputRequests[0];
160
205
  const response =
161
206
  requestToolName(request) === "ask_question"
162
207
  ? questionResponse(request, await handlers.askQuestion(request))
163
208
  : approvalResponse(request, await handlers.askApproval(request));
164
- result = await postTurn(host, token, {
165
- conversationId: result.conversationId,
166
- inputResponses: [response],
167
- });
209
+ renderer.reset();
210
+ leg = await postTurnStream(
211
+ host,
212
+ token,
213
+ {
214
+ conversationId: result.conversationId,
215
+ inputResponses: [response],
216
+ },
217
+ { render: renderer },
218
+ );
219
+ assistantTextStreamed ||= leg.assistantTextStreamed;
220
+ result = leg.result;
168
221
  }
169
- return result;
222
+
223
+ return { ...result, assistantTextStreamed };
170
224
  }
@@ -0,0 +1,399 @@
1
+ // Product Neagen TUI: Eve's terminal runner, Neagen's authenticated /api/turns
2
+ // transport. This keeps Eve's TUI/approval/tool rendering while preserving the
3
+ // app's wallet, memory, billing, conversation, and model-selection lifecycle.
4
+
5
+ import { createRequire } from "node:module";
6
+ import { dirname, join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
9
+ import {
10
+ getSelectedModel,
11
+ listModels,
12
+ probeHost,
13
+ resolveIdentity,
14
+ selectModel,
15
+ } from "./api.mjs";
16
+ import { runDeviceGrantLogin } from "./device-login.mjs";
17
+ import { parseSseChunk } from "./turn-stream.mjs";
18
+
19
+ const require = createRequire(import.meta.url);
20
+
21
+ function eveInternalUrl(relativePath) {
22
+ const root = dirname(require.resolve("eve/package.json"));
23
+ return pathToFileURL(join(root, relativePath)).href;
24
+ }
25
+
26
+ async function loadEveTuiRunner() {
27
+ const mod = await import(eveInternalUrl("dist/src/cli/dev/tui/runner.js"));
28
+ return mod.EveTUIRunner;
29
+ }
30
+
31
+ function normalizeHost(host) {
32
+ return host.replace(/\/$/, "");
33
+ }
34
+
35
+ function modelAliases(input) {
36
+ const value = input.trim();
37
+ const lower = value.toLowerCase();
38
+ if (lower === "deepseek" || lower === "flash") return "deepseek/deepseek-v4-flash";
39
+ if (lower === "sonnet" || lower === "claude" || lower === "claude-sonnet") {
40
+ return "anthropic/claude-sonnet-4.6";
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function formatModel(model) {
46
+ if (!model) return "unknown";
47
+ const label = model.providerLabel && model.name ? `${model.providerLabel} ${model.name}` : model.id;
48
+ return `${label} (${model.id})`;
49
+ }
50
+
51
+ function agentInfoForModel(model, identity) {
52
+ return {
53
+ agent: {
54
+ name: identity?.neagenName ?? "Neagen",
55
+ model: {
56
+ id: model?.id ?? "unknown",
57
+ provider: model?.provider ?? model?.providerLabel,
58
+ },
59
+ },
60
+ diagnostics: {
61
+ discoveryErrors: 0,
62
+ discoveryWarnings: 0,
63
+ },
64
+ };
65
+ }
66
+
67
+ function inputRequestToolName(request) {
68
+ return request?.action?.toolName ?? "input";
69
+ }
70
+
71
+ function isNeagenCommitEvent(eventName) {
72
+ return eventName === "neagen.finalized" ||
73
+ eventName === "neagen.waiting_saved" ||
74
+ eventName === "neagen.gate_declined";
75
+ }
76
+
77
+ function neagenErrorToSessionFailed(data) {
78
+ return {
79
+ type: "session.failed",
80
+ data: {
81
+ error: {
82
+ code: data?.code ?? "turn_failed",
83
+ message: data?.message ?? "Turn failed.",
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ function isBoundaryEvent(event) {
90
+ return event?.type === "session.waiting" ||
91
+ event?.type === "session.completed" ||
92
+ event?.type === "session.failed";
93
+ }
94
+
95
+ async function* readProductTurnEvents(response, onProductEvent) {
96
+ if (!response.ok) {
97
+ let message = `HTTP ${response.status}`;
98
+ const text = await response.text();
99
+ try {
100
+ message = JSON.parse(text)?.error?.message ?? message;
101
+ } catch {
102
+ if (text.trim()) message = text.trim();
103
+ }
104
+ throw new Error(message);
105
+ }
106
+
107
+ const reader = response.body?.getReader();
108
+ if (!reader) throw new Error("Turn stream is not readable.");
109
+
110
+ const decoder = new TextDecoder();
111
+ let buffer = "";
112
+ let heldBoundary;
113
+
114
+ async function* handleEntry(entry) {
115
+ const { event, data } = entry;
116
+
117
+ if (event === "done") {
118
+ if (heldBoundary) {
119
+ yield heldBoundary;
120
+ heldBoundary = undefined;
121
+ }
122
+ return;
123
+ }
124
+
125
+ if (event === "neagen.error") {
126
+ yield neagenErrorToSessionFailed(data);
127
+ heldBoundary = undefined;
128
+ return;
129
+ }
130
+
131
+ if (isNeagenCommitEvent(event)) {
132
+ onProductEvent(event, data);
133
+ if (heldBoundary) {
134
+ yield heldBoundary;
135
+ heldBoundary = undefined;
136
+ }
137
+ return;
138
+ }
139
+
140
+ const eveEvent = { type: event, data };
141
+ if (isBoundaryEvent(eveEvent)) {
142
+ heldBoundary = eveEvent;
143
+ return;
144
+ }
145
+ yield eveEvent;
146
+ }
147
+
148
+ while (true) {
149
+ const { done, value } = await reader.read();
150
+ if (done) break;
151
+ buffer += decoder.decode(value, { stream: true });
152
+ const parsed = parseSseChunk(buffer);
153
+ buffer = parsed.trailing;
154
+ for (const entry of parsed.events) {
155
+ yield* handleEntry(entry);
156
+ }
157
+ }
158
+
159
+ if (buffer.trim()) {
160
+ const parsed = parseSseChunk(`${buffer}\n\n`);
161
+ for (const entry of parsed.events) {
162
+ yield* handleEntry(entry);
163
+ }
164
+ }
165
+
166
+ if (heldBoundary) yield heldBoundary;
167
+ }
168
+
169
+ class ProductMessageResponse {
170
+ constructor(events) {
171
+ this.events = events;
172
+ }
173
+
174
+ [Symbol.asyncIterator]() {
175
+ return this.events[Symbol.asyncIterator]();
176
+ }
177
+ }
178
+
179
+ class ProductSession {
180
+ constructor(client, state = {}) {
181
+ this.client = client;
182
+ this.conversationId = state.conversationId;
183
+ }
184
+
185
+ get state() {
186
+ return {
187
+ sessionId: this.conversationId,
188
+ streamIndex: 0,
189
+ conversationId: this.conversationId,
190
+ };
191
+ }
192
+
193
+ async send(input) {
194
+ const payload = typeof input === "string" ? { message: input } : (input ?? {});
195
+ const body = payload.inputResponses?.length
196
+ ? { conversationId: this.conversationId, inputResponses: payload.inputResponses }
197
+ : { conversationId: this.conversationId, body: payload.message };
198
+
199
+ if (!body.body && !body.inputResponses) {
200
+ throw new Error("Nothing to send.");
201
+ }
202
+ if (body.inputResponses && !body.conversationId) {
203
+ throw new Error("No Neagen conversation is waiting for approval.");
204
+ }
205
+
206
+ const response = await fetch(`${this.client.host}/api/turns`, {
207
+ method: "POST",
208
+ headers: {
209
+ authorization: `Bearer ${this.client.token}`,
210
+ "content-type": "application/json",
211
+ accept: "text/event-stream",
212
+ },
213
+ body: JSON.stringify(body),
214
+ signal: payload.signal,
215
+ });
216
+
217
+ const events = readProductTurnEvents(response, (event, data) => {
218
+ if (data?.conversationId) this.conversationId = data.conversationId;
219
+ if (event === "neagen.gate_declined" && data?.conversationId) {
220
+ this.conversationId = data.conversationId;
221
+ }
222
+ });
223
+ return new ProductMessageResponse(events);
224
+ }
225
+ }
226
+
227
+ class ProductClient {
228
+ constructor({ host, token, identity }) {
229
+ this.host = normalizeHost(host);
230
+ this.token = token;
231
+ this.identity = identity;
232
+ this.cachedSelection = undefined;
233
+ }
234
+
235
+ session(state) {
236
+ return new ProductSession(this, state);
237
+ }
238
+
239
+ async selectedModel() {
240
+ const selection = await getSelectedModel(this.host, this.token);
241
+ this.cachedSelection = selection;
242
+ return selection.activeModel;
243
+ }
244
+
245
+ async info() {
246
+ let model;
247
+ try {
248
+ model = await this.selectedModel();
249
+ } catch {
250
+ model = this.cachedSelection?.activeModel;
251
+ }
252
+ return agentInfoForModel(model, this.identity);
253
+ }
254
+ }
255
+
256
+ function modelOptions(models, activeId) {
257
+ return models.map((model) => ({
258
+ value: model.id,
259
+ label: `${model.id === activeId ? "● " : ""}${model.name ?? model.id}`,
260
+ description: `${model.providerLabel ?? model.provider ?? ""} · ${model.id}`,
261
+ }));
262
+ }
263
+
264
+ function createModelCommandHandler({ host, token, productClient, title }) {
265
+ return {
266
+ async handle(command, context) {
267
+ if (command.name !== "model") {
268
+ return { message: `/${command.name} is not supported in the Neagen TUI.` };
269
+ }
270
+
271
+ const raw = command.argument.trim();
272
+ if (raw) {
273
+ const updated = await selectModel(host, token, modelAliases(raw));
274
+ productClient.cachedSelection = updated;
275
+ context.renderer.renderAgentHeader?.({
276
+ name: title,
277
+ serverUrl: host,
278
+ info: agentInfoForModel(updated.activeModel, productClient.identity),
279
+ });
280
+ return { message: `Switched model to ${formatModel(updated.activeModel)}.` };
281
+ }
282
+
283
+ const [selection, models] = await Promise.all([
284
+ getSelectedModel(host, token),
285
+ listModels(host, token),
286
+ ]);
287
+ productClient.cachedSelection = selection;
288
+ const activeId = selection.activeModel?.id;
289
+ const flow = context.renderer.setupFlow;
290
+ if (!flow) {
291
+ return {
292
+ message: `Current model: ${formatModel(selection.activeModel)}. Use /model provider/model to switch.`,
293
+ };
294
+ }
295
+
296
+ flow.begin("Choose Neagen model", "pulse");
297
+ try {
298
+ const chosen = await flow.readSelect({
299
+ kind: "search",
300
+ message: "Pick the model Neagen should use",
301
+ placeholder: "Filter models",
302
+ initialValue: activeId,
303
+ options: modelOptions(models, activeId),
304
+ });
305
+ const modelId = chosen?.[0];
306
+ if (!modelId) return { message: "/model cancelled." };
307
+ if (modelId === activeId) return { message: `Already using ${formatModel(selection.activeModel)}.` };
308
+ const updated = await selectModel(host, token, modelId);
309
+ productClient.cachedSelection = updated;
310
+ context.renderer.renderAgentHeader?.({
311
+ name: title,
312
+ serverUrl: host,
313
+ info: agentInfoForModel(updated.activeModel, productClient.identity),
314
+ });
315
+ return { message: `Switched model to ${formatModel(updated.activeModel)}.` };
316
+ } finally {
317
+ flow.end({ preserveDiagnostics: false });
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+ async function ensureReachable(host) {
324
+ process.stdout.write(`Connecting to ${host}...\n`);
325
+ await probeHost(host);
326
+ }
327
+
328
+ async function ensureAuthenticated({ host, profile }) {
329
+ let token = loadToken(profile, host);
330
+ if (token) {
331
+ const identity = await resolveIdentity(host, token);
332
+ if (identity) return { token, identity };
333
+ clearCreds(profile);
334
+ token = null;
335
+ }
336
+ token = await runDeviceGrantLogin({ host, profile });
337
+ const identity = await resolveIdentity(host, token);
338
+ if (!identity) {
339
+ clearCreds(profile);
340
+ throw new Error("Session could not be verified after login.");
341
+ }
342
+ return { token, identity };
343
+ }
344
+
345
+ export async function runProductTui({ profile } = {}) {
346
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
347
+ throw new Error("neagen requires an interactive terminal.");
348
+ }
349
+
350
+ const host = normalizeHost(resolveHost());
351
+ assertTransportSafe(host);
352
+ await ensureReachable(host);
353
+ const { token, identity } = await ensureAuthenticated({ host, profile });
354
+ const EveTUIRunner = await loadEveTuiRunner();
355
+ const client = new ProductClient({ host, token, identity });
356
+ const title = identity.neagenName ?? "neagen";
357
+ const session = client.session();
358
+
359
+ const selected = await client.selectedModel().catch(() => undefined);
360
+ process.stdout.write(`Agent online. Model: ${selected?.id ?? "unknown"}.\n`);
361
+
362
+ await new EveTUIRunner({
363
+ session,
364
+ client,
365
+ serverUrl: host,
366
+ name: title,
367
+ tools: "full",
368
+ reasoning: "full",
369
+ assistantResponseStats: "tokensPerSecond",
370
+ promptCommandHandler: createModelCommandHandler({ host, token, productClient: client, title }),
371
+ availablePromptCommands: [
372
+ { name: "help", aliases: [], description: "Show available commands", takesArgument: false },
373
+ { name: "new", aliases: [], description: "Start a fresh session", takesArgument: false },
374
+ {
375
+ name: "model",
376
+ aliases: [],
377
+ description: "Pick the Neagen model",
378
+ argumentHint: "[provider/model]",
379
+ takesArgument: true,
380
+ },
381
+ {
382
+ name: "loglevel",
383
+ aliases: [],
384
+ description: "Show or hide logs",
385
+ argumentHint: "[all|stderr|sandbox|none]",
386
+ takesArgument: true,
387
+ },
388
+ { name: "exit", aliases: ["quit"], description: "Quit", takesArgument: false },
389
+ ],
390
+ }).run();
391
+ }
392
+
393
+ export {
394
+ createModelCommandHandler,
395
+ ProductClient,
396
+ ProductSession,
397
+ readProductTurnEvents,
398
+ inputRequestToolName,
399
+ };
package/lib/tui.mjs CHANGED
@@ -2,7 +2,6 @@
2
2
  // Login happens here (device grant) when no valid session exists.
3
3
 
4
4
  import readline from "node:readline/promises";
5
- import { clearLine, cursorTo } from "node:readline";
6
5
  import { stdin } from "node:process";
7
6
  import { dim, bold } from "./ansi.mjs";
8
7
  import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
@@ -21,6 +20,7 @@ import {
21
20
  sendWakeSyncTurn,
22
21
  summarizeResult,
23
22
  } from "./eve-chat.mjs";
23
+ import { createTurnStreamRenderer } from "./turn-stream.mjs";
24
24
  import { pickModel, readPromptLine } from "./picker.mjs";
25
25
  import { SLASH_COMMANDS, slashHelpLines } from "./slash.mjs";
26
26
  import { subscribeInboxWakeAbly } from "./wake.mjs";
@@ -160,31 +160,19 @@ async function askQuestion(rl, request) {
160
160
  return rl.question(`\n${request.prompt}\n${options ? `${options}\n` : ""}> `);
161
161
  }
162
162
 
163
- function startStatus(label) {
164
- if (!process.stdout.isTTY) return { stop() {} };
165
-
166
- let frame = 0;
167
- let active = true;
168
- const frames = ["", ".", "..", "..."];
169
- const render = () => {
170
- if (!active) return;
171
- cursorTo(process.stdout, 0);
172
- process.stdout.write(`${label}${frames[frame % frames.length]}`);
173
- clearLine(process.stdout, 1);
174
- frame += 1;
175
- };
176
-
177
- render();
178
- const timer = setInterval(render, 450);
179
- return {
180
- stop() {
181
- if (!active) return;
182
- active = false;
183
- clearInterval(timer);
184
- cursorTo(process.stdout, 0);
185
- clearLine(process.stdout, 0);
186
- },
187
- };
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
+ }
188
176
  }
189
177
 
190
178
  await ensureReachable();
@@ -216,23 +204,17 @@ function runExclusive(fn) {
216
204
  }
217
205
 
218
206
  async function sendTurn(message) {
219
- const status = startStatus("thinking");
220
207
  const result = await sendTransportTurn({
221
208
  host,
222
209
  token,
223
210
  conversationId,
224
211
  message,
212
+ render: turnRenderer,
225
213
  handlers: {
226
- askApproval: (request) => {
227
- status.stop();
228
- return askApproval(rl, request);
229
- },
230
- askQuestion: (request) => {
231
- status.stop();
232
- return askQuestion(rl, request);
233
- },
214
+ askApproval: (request) => askApproval(rl, request),
215
+ askQuestion: (request) => askQuestion(rl, request),
234
216
  },
235
- }).finally(() => status.stop());
217
+ });
236
218
  conversationId = result?.conversationId ?? conversationId;
237
219
  return result;
238
220
  }
@@ -247,12 +229,13 @@ try {
247
229
  host,
248
230
  token,
249
231
  event: ev,
232
+ render: turnRenderer,
250
233
  handlers: {
251
234
  askApproval: (request) => askApproval(rl, request),
252
235
  askQuestion: (request) => askQuestion(rl, request),
253
236
  },
254
237
  });
255
- process.stdout.write(`\n${summarizeResult(result)}\n\n`);
238
+ printTurnOutcome(result);
256
239
  }).catch((e) => {
257
240
  process.stderr.write(`! wake sync failed: ${e?.message ?? e}\n`);
258
241
  });
@@ -316,7 +299,7 @@ try {
316
299
  }
317
300
 
318
301
  const result = await runExclusive(() => sendTurn(line));
319
- process.stdout.write(`\n${summarizeResult(result)}\n\n`);
302
+ printTurnOutcome(result);
320
303
  } catch (e) {
321
304
  process.stderr.write(`! ${e?.message ?? e}\n`);
322
305
  }
@@ -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
- if (profile) process.env.NEAGEN_PROFILE = profile;
66
- await import("./lib/tui.mjs");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neagen",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Your personal networking agent, in the terminal. Chat, agent-mail, and files against your Neagen — passkey login, no passwords.",
5
5
  "type": "module",
6
6
  "bin": {