@tspappsen/elamax 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1026 @@
1
+ import * as readline from "readline";
2
+ import * as http from "http";
3
+ import { execFile } from "child_process";
4
+ import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
5
+ import { HISTORY_PATH, API_TOKEN_PATH, TUI_DEBUG_LOG_PATH, ensureMaxHome } from "../paths.js";
6
+ const API_BASE = process.env.MAX_API_URL || "http://127.0.0.1:7777";
7
+ // Load API auth token (if it exists)
8
+ let apiToken = null;
9
+ try {
10
+ if (existsSync(API_TOKEN_PATH)) {
11
+ apiToken = readFileSync(API_TOKEN_PATH, "utf-8").trim();
12
+ }
13
+ }
14
+ catch {
15
+ console.error("Warning: Could not read API token from " + API_TOKEN_PATH + " — requests may fail.");
16
+ }
17
+ function authHeaders() {
18
+ return apiToken ? { Authorization: `Bearer ${apiToken}` } : {};
19
+ }
20
+ // ── ANSI helpers ──────────────────────────────────────────
21
+ const C = {
22
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
23
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
24
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
25
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
26
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
27
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
28
+ magenta: (s) => `\x1b[35m${s}\x1b[0m`,
29
+ boldCyan: (s) => `\x1b[1;36m${s}\x1b[0m`,
30
+ bgDim: (s) => `\x1b[48;5;236m${s}\x1b[0m`,
31
+ coral: (s) => `\x1b[38;2;255;127;80m${s}\x1b[0m`,
32
+ boldWhite: (s) => `\x1b[1;97m${s}\x1b[0m`,
33
+ blue: (s) => `\x1b[38;2;14;165;233m${s}\x1b[0m`,
34
+ };
35
+ // ── Layout constants ─────────────────────────────────────
36
+ const LABEL_PAD = " "; // 10-char indent for continuation lines
37
+ const MAX_LABEL = ` ${C.cyan("MAX")} `;
38
+ const TUI_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test((process.env.MAX_TUI_DEBUG || "").trim());
39
+ let debugWriteFailureReported = false;
40
+ function previewForDebug(text, max = 120) {
41
+ return text
42
+ .slice(0, max)
43
+ .replace(/\r/g, "\\r")
44
+ .replace(/\n/g, "\\n")
45
+ .replace(/\t/g, "\\t");
46
+ }
47
+ function debugLog(event, data = {}) {
48
+ if (!TUI_DEBUG_ENABLED)
49
+ return;
50
+ const entry = {
51
+ ts: new Date().toISOString(),
52
+ event,
53
+ ...data,
54
+ };
55
+ try {
56
+ appendFileSync(TUI_DEBUG_LOG_PATH, JSON.stringify(entry) + "\n");
57
+ }
58
+ catch (err) {
59
+ if (debugWriteFailureReported)
60
+ return;
61
+ debugWriteFailureReported = true;
62
+ const msg = err instanceof Error ? err.message : String(err);
63
+ process.stderr.write(`\n[max] failed to write TUI debug log: ${msg}\n`);
64
+ }
65
+ }
66
+ // ── Markdown → ANSI rendering ────────────────────────────
67
+ /** Render a single line of markdown to ANSI (used by both streaming and batch). */
68
+ function renderLine(line, inCodeBlock) {
69
+ if (inCodeBlock) {
70
+ return ` ${C.dim("│")} ${line}`;
71
+ }
72
+ if (/^[-*_]{3,}\s*$/.test(line))
73
+ return C.dim("──────────────────────────────────");
74
+ if (line.startsWith("### "))
75
+ return C.coral(line.slice(4));
76
+ if (line.startsWith("## "))
77
+ return C.boldWhite(line.slice(3));
78
+ if (line.startsWith("# "))
79
+ return C.boldWhite(line.slice(2));
80
+ if (line.startsWith("> "))
81
+ return `${C.dim("│")} ${C.dim(line.slice(2))}`;
82
+ if (/^ {2,}[-*] /.test(line))
83
+ return ` ◦ ${line.replace(/^ +[-*] /, "")}`;
84
+ if (/^[-*] /.test(line))
85
+ return ` • ${line.slice(2)}`;
86
+ if (/^\d+\. /.test(line))
87
+ return ` ${line}`;
88
+ return line;
89
+ }
90
+ /** Apply inline formatting (bold, code, links, etc.) to already-rendered text. */
91
+ function applyInlineFormatting(text) {
92
+ return text
93
+ .replace(/\*\*\*(.+?)\*\*\*/g, `\x1b[1;3m$1\x1b[0m`)
94
+ .replace(/\*\*(.+?)\*\*/g, `\x1b[1m$1\x1b[0m`)
95
+ .replace(/~~(.+?)~~/g, `\x1b[9m$1\x1b[0m`)
96
+ .replace(/`([^`]+)`/g, C.yellow("$1"))
97
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) => `${t} ${C.dim(`(${u})`)}`);
98
+ }
99
+ /** Strip ANSI escape sequences to measure visible text width. */
100
+ function stripAnsi(str) {
101
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
102
+ }
103
+ /** Wrap ANSI-formatted text at word boundaries to fit within maxWidth visible columns. */
104
+ function wrapText(text, maxWidth) {
105
+ if (maxWidth <= 0 || stripAnsi(text).length <= maxWidth)
106
+ return [text];
107
+ const RESET = "\x1b[0m";
108
+ const lines = [];
109
+ let remaining = text;
110
+ while (remaining.length > 0) {
111
+ if (stripAnsi(remaining).length <= maxWidth) {
112
+ lines.push(remaining);
113
+ break;
114
+ }
115
+ let visCount = 0;
116
+ let i = 0;
117
+ let lastSpaceI = -1;
118
+ const ansiStack = [];
119
+ let ansiAtSpace = [];
120
+ while (i < remaining.length && visCount < maxWidth) {
121
+ const match = remaining.slice(i).match(/^\x1b\[[0-9;]*m/);
122
+ if (match) {
123
+ if (match[0] === RESET)
124
+ ansiStack.length = 0;
125
+ else
126
+ ansiStack.push(match[0]);
127
+ i += match[0].length;
128
+ }
129
+ else {
130
+ if (remaining[i] === " ") {
131
+ lastSpaceI = i;
132
+ ansiAtSpace = [...ansiStack];
133
+ }
134
+ visCount++;
135
+ i++;
136
+ }
137
+ }
138
+ let breakI;
139
+ let openAnsi;
140
+ if (lastSpaceI > 0) {
141
+ breakI = lastSpaceI;
142
+ openAnsi = ansiAtSpace;
143
+ }
144
+ else {
145
+ breakI = i;
146
+ openAnsi = [...ansiStack];
147
+ }
148
+ let line = remaining.slice(0, breakI);
149
+ remaining = remaining.slice(breakI + (remaining[breakI] === " " ? 1 : 0));
150
+ if (openAnsi.length > 0) {
151
+ line += RESET;
152
+ if (remaining.length > 0)
153
+ remaining = openAnsi.join("") + remaining;
154
+ }
155
+ lines.push(line);
156
+ }
157
+ return lines;
158
+ }
159
+ /** Render a complete markdown document to ANSI (used for proactive/background messages). */
160
+ function renderMarkdown(text) {
161
+ let inCodeBlock = false;
162
+ const rendered = text.split("\n").map((line) => {
163
+ if (/^```/.test(line)) {
164
+ if (inCodeBlock) {
165
+ inCodeBlock = false;
166
+ return "";
167
+ }
168
+ inCodeBlock = true;
169
+ const lang = line.slice(3).trim();
170
+ return lang ? C.dim(lang) : "";
171
+ }
172
+ return renderLine(line, inCodeBlock);
173
+ });
174
+ return applyInlineFormatting(rendered.join("\n"));
175
+ }
176
+ /** Write a rendered message with a role label (MAX/SYS). */
177
+ function writeLabeled(role, text) {
178
+ const label = role === "max"
179
+ ? MAX_LABEL
180
+ : ` ${C.dim("SYS")} `;
181
+ const availWidth = (process.stdout.columns || 80) - 10;
182
+ const lines = text.split("\n");
183
+ for (let i = 0; i < lines.length; i++) {
184
+ const prefix = i === 0 ? label : LABEL_PAD;
185
+ const isCodeLine = stripAnsi(lines[i]).startsWith(" \u2502 ");
186
+ if (isCodeLine) {
187
+ process.stdout.write(prefix + lines[i] + "\n");
188
+ }
189
+ else {
190
+ const wrapped = wrapText(lines[i], availWidth);
191
+ process.stdout.write(prefix + wrapped.join("\n" + LABEL_PAD) + "\n");
192
+ }
193
+ }
194
+ }
195
+ // ── Streaming markdown renderer ──────────────────────────
196
+ let streamLineBuffer = "";
197
+ let inStreamCodeBlock = false;
198
+ let streamIsFirstLine = true;
199
+ /** Get the prefix for the current stream line (label or padding). */
200
+ function streamPrefix() {
201
+ return streamIsFirstLine ? MAX_LABEL : LABEL_PAD;
202
+ }
203
+ function stripLeadingStreamNewlines(text) {
204
+ if (!streamIsFirstLine || streamLineBuffer.length > 0)
205
+ return text;
206
+ const stripped = text.replace(/^(?:\r?\n)+/, "");
207
+ if (stripped.length !== text.length) {
208
+ debugLog("stream-strip-leading-newlines", {
209
+ requestId: activeRequestId,
210
+ removedChars: text.length - stripped.length,
211
+ originalPreview: previewForDebug(text),
212
+ });
213
+ }
214
+ return stripped;
215
+ }
216
+ /** Clear the current visual line (handles terminal wrapping). */
217
+ function clearVisualLine(charCount) {
218
+ const cols = process.stdout.columns || 80;
219
+ const up = Math.ceil(Math.max(charCount, 1) / cols) - 1;
220
+ debugLog("clear-visual-line", { requestId: activeRequestId, charCount, cols, up });
221
+ if (up > 0)
222
+ process.stdout.write(`\x1b[${up}A`);
223
+ process.stdout.write(`\r\x1b[J`);
224
+ }
225
+ /** Render a buffered line and write it with the appropriate prefix. */
226
+ function writeRenderedStreamLine(line) {
227
+ const prefix = streamPrefix();
228
+ if (/^```/.test(line)) {
229
+ if (inStreamCodeBlock) {
230
+ inStreamCodeBlock = false;
231
+ }
232
+ else {
233
+ inStreamCodeBlock = true;
234
+ const lang = line.slice(3).trim();
235
+ process.stdout.write(prefix + (lang ? C.dim(lang) : ""));
236
+ }
237
+ }
238
+ else {
239
+ const rendered = applyInlineFormatting(renderLine(line, inStreamCodeBlock));
240
+ if (inStreamCodeBlock) {
241
+ process.stdout.write(prefix + rendered);
242
+ }
243
+ else {
244
+ const availWidth = (process.stdout.columns || 80) - 10;
245
+ const wrapped = wrapText(rendered, availWidth);
246
+ process.stdout.write(prefix + wrapped.join("\n" + LABEL_PAD));
247
+ }
248
+ }
249
+ process.stdout.write("\n");
250
+ streamIsFirstLine = false;
251
+ }
252
+ /** Process a chunk of streaming text, rendering complete lines with labels. */
253
+ function writeStreamChunk(newText) {
254
+ debugLog("stream-chunk", {
255
+ requestId: activeRequestId,
256
+ length: newText.length,
257
+ preview: previewForDebug(newText),
258
+ startsWithNewline: /^(?:\r?\n)/.test(newText),
259
+ });
260
+ let pos = 0;
261
+ while (pos < newText.length) {
262
+ const nl = newText.indexOf("\n", pos);
263
+ if (nl === -1) {
264
+ // No newline — buffer and write raw with prefix if at line start
265
+ const partial = newText.slice(pos);
266
+ if (streamLineBuffer.length === 0) {
267
+ process.stdout.write(streamPrefix());
268
+ }
269
+ streamLineBuffer += partial;
270
+ process.stdout.write(partial);
271
+ return;
272
+ }
273
+ // Got a complete line
274
+ const segment = newText.slice(pos, nl);
275
+ const hadPartial = streamLineBuffer.length > 0;
276
+ streamLineBuffer += segment;
277
+ if (hadPartial) {
278
+ // Clear the partially-written raw text
279
+ clearVisualLine(10 + streamLineBuffer.length);
280
+ }
281
+ if (streamLineBuffer.length === 0 && !hadPartial) {
282
+ // Empty line
283
+ process.stdout.write(streamPrefix() + "\n");
284
+ streamIsFirstLine = false;
285
+ }
286
+ else {
287
+ writeRenderedStreamLine(streamLineBuffer);
288
+ }
289
+ streamLineBuffer = "";
290
+ pos = nl + 1;
291
+ }
292
+ }
293
+ /** Flush any remaining partial line and reset streaming state. */
294
+ function flushStreamState() {
295
+ if (streamLineBuffer.length > 0) {
296
+ clearVisualLine(10 + streamLineBuffer.length);
297
+ writeRenderedStreamLine(streamLineBuffer);
298
+ }
299
+ streamLineBuffer = "";
300
+ inStreamCodeBlock = false;
301
+ streamIsFirstLine = true;
302
+ }
303
+ // ── Thinking indicator ────────────────────────────────────
304
+ let thinkingTimer;
305
+ let thinkingFrame = 0;
306
+ let thinkingVisible = false;
307
+ const thinkingFrames = ["Thinking", "Thinking.", "Thinking..", "Thinking..."];
308
+ function startThinking() {
309
+ stopThinking("restart-thinking");
310
+ thinkingFrame = 0;
311
+ thinkingVisible = true;
312
+ process.stdout.write(`\n${MAX_LABEL}${C.dim(thinkingFrames[0])}`);
313
+ debugLog("thinking-start", {
314
+ requestId: activeRequestId,
315
+ frame: thinkingFrames[0],
316
+ msSinceSubmit: activeRequestStartedAt > 0 ? Date.now() - activeRequestStartedAt : null,
317
+ });
318
+ thinkingTimer = setInterval(() => {
319
+ thinkingFrame = (thinkingFrame + 1) % thinkingFrames.length;
320
+ process.stdout.write(`\r\x1b[K${MAX_LABEL}${C.dim(thinkingFrames[thinkingFrame])}`);
321
+ debugLog("thinking-tick", {
322
+ requestId: activeRequestId,
323
+ frameIndex: thinkingFrame,
324
+ frame: thinkingFrames[thinkingFrame],
325
+ });
326
+ }, 400);
327
+ }
328
+ function stopThinking(reason = "unspecified") {
329
+ const hadTimer = Boolean(thinkingTimer);
330
+ const wasVisible = thinkingVisible;
331
+ if (thinkingTimer) {
332
+ clearInterval(thinkingTimer);
333
+ thinkingTimer = undefined;
334
+ }
335
+ if (thinkingVisible) {
336
+ process.stdout.write(`\r\x1b[K`);
337
+ thinkingVisible = false;
338
+ }
339
+ debugLog("thinking-stop", {
340
+ requestId: activeRequestId,
341
+ reason,
342
+ hadTimer,
343
+ wasVisible,
344
+ });
345
+ }
346
+ // ── State ─────────────────────────────────────────────────
347
+ let connectionId;
348
+ let isStreaming = false;
349
+ let streamedContent = "";
350
+ let lastResponse = "";
351
+ let activeRequestId = 0;
352
+ let activeRequestStartedAt = 0;
353
+ // ── Persistent history ────────────────────────────────────
354
+ const MAX_HISTORY = 1000;
355
+ function loadHistory() {
356
+ try {
357
+ if (existsSync(HISTORY_PATH)) {
358
+ return readFileSync(HISTORY_PATH, "utf-8")
359
+ .split("\n")
360
+ .filter(Boolean)
361
+ .slice(-MAX_HISTORY);
362
+ }
363
+ }
364
+ catch { /* ignore */ }
365
+ return [];
366
+ }
367
+ function saveHistoryLine(line) {
368
+ try {
369
+ appendFileSync(HISTORY_PATH, line + "\n");
370
+ }
371
+ catch { /* ignore */ }
372
+ }
373
+ function trimHistoryFile() {
374
+ try {
375
+ if (!existsSync(HISTORY_PATH))
376
+ return;
377
+ const lines = readFileSync(HISTORY_PATH, "utf-8").split("\n").filter(Boolean);
378
+ if (lines.length > MAX_HISTORY) {
379
+ writeFileSync(HISTORY_PATH, lines.slice(-MAX_HISTORY).join("\n") + "\n");
380
+ }
381
+ }
382
+ catch { /* ignore */ }
383
+ }
384
+ // ── Readline setup ────────────────────────────────────────
385
+ ensureMaxHome();
386
+ debugLog("session-start", {
387
+ pid: process.pid,
388
+ cwd: process.cwd(),
389
+ stdinIsTTY: Boolean(process.stdin.isTTY),
390
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
391
+ columns: process.stdout.columns || null,
392
+ logPath: TUI_DEBUG_LOG_PATH,
393
+ });
394
+ const history = loadHistory();
395
+ const rl = readline.createInterface({
396
+ input: process.stdin,
397
+ output: process.stdout,
398
+ prompt: ` ${C.coral("›")} `,
399
+ history,
400
+ historySize: MAX_HISTORY,
401
+ });
402
+ // ── Welcome banner ────────────────────────────────────────
403
+ function showBanner() {
404
+ console.clear();
405
+ console.log();
406
+ console.log();
407
+ console.log(C.boldWhite(" ██ ██ █████ ██ ██"));
408
+ console.log(C.boldWhite(" ███ ███ ██ ██ ██ ██"));
409
+ console.log(C.boldWhite(" ██ ████ ██ ███████ ███"));
410
+ console.log(C.boldWhite(" ██ ██ ██ ██ ██ ██ ██"));
411
+ console.log(C.boldWhite(" ██ ██ ██ ██ ██ ██") + " " + C.coral("●"));
412
+ console.log();
413
+ console.log(C.dim(" personal AI assistant for developers"));
414
+ console.log();
415
+ }
416
+ function showStatus(model, skillCount, routerInfo) {
417
+ const parts = [];
418
+ if (model)
419
+ parts.push(`${C.dim("model:")} ${C.cyan(model)}`);
420
+ if (routerInfo?.enabled) {
421
+ parts.push(C.cyan("⚡ auto"));
422
+ }
423
+ if (skillCount !== undefined)
424
+ parts.push(`${C.dim("skills:")} ${C.cyan(String(skillCount))}`);
425
+ if (parts.length)
426
+ console.log(` ${parts.join(" ")}`);
427
+ console.log();
428
+ console.log(C.dim(" /help for commands · esc to cancel"));
429
+ console.log();
430
+ }
431
+ function fetchStartupInfo() {
432
+ let model = "unknown";
433
+ let skillCount = 0;
434
+ let routerInfo;
435
+ let done = 0;
436
+ const check = () => {
437
+ done++;
438
+ if (done === 3)
439
+ showStatus(model, skillCount, routerInfo);
440
+ };
441
+ apiGetSilent("/model", (data) => { model = data?.model || "unknown"; check(); });
442
+ apiGetSilent("/skills", (data) => { skillCount = Array.isArray(data) ? data.length : 0; check(); });
443
+ apiGetSilent("/auto", (data) => { if (data)
444
+ routerInfo = { enabled: Boolean(data.enabled) }; check(); });
445
+ }
446
+ // ── SSE connection ────────────────────────────────────────
447
+ function connectSSE() {
448
+ const url = new URL("/stream", API_BASE);
449
+ http.get(url, { headers: authHeaders() }, (res) => {
450
+ console.log(C.green(" ● ") + C.dim("max — connected"));
451
+ fetchStartupInfo();
452
+ let buffer = "";
453
+ res.on("data", (chunk) => {
454
+ buffer += chunk.toString();
455
+ const lines = buffer.split("\n");
456
+ buffer = lines.pop() || "";
457
+ for (const line of lines) {
458
+ if (line.startsWith("data: ")) {
459
+ try {
460
+ const event = JSON.parse(line.slice(6));
461
+ if (event.type === "connected") {
462
+ connectionId = event.connectionId;
463
+ debugLog("sse-connected", { connectionId });
464
+ }
465
+ else if (event.type === "delta") {
466
+ const full = event.content || "";
467
+ const baseLength = isStreaming ? streamedContent.length : 0;
468
+ if (!isStreaming) {
469
+ stopThinking("first-delta");
470
+ isStreaming = true;
471
+ streamedContent = "";
472
+ streamLineBuffer = "";
473
+ inStreamCodeBlock = false;
474
+ streamIsFirstLine = true;
475
+ debugLog("stream-first-delta", {
476
+ requestId: activeRequestId,
477
+ msSinceSubmit: activeRequestStartedAt > 0 ? Date.now() - activeRequestStartedAt : null,
478
+ fullLength: full.length,
479
+ newLength: full.length,
480
+ startsWithNewline: /^(?:\r?\n)/.test(full),
481
+ });
482
+ }
483
+ // Content is cumulative — only print the new part
484
+ const newText = full.slice(baseLength);
485
+ if (newText) {
486
+ const normalized = stripLeadingStreamNewlines(newText);
487
+ debugLog("stream-delta", {
488
+ requestId: activeRequestId,
489
+ fullLength: full.length,
490
+ rawLength: newText.length,
491
+ normalizedLength: normalized.length,
492
+ preview: previewForDebug(normalized),
493
+ });
494
+ if (normalized)
495
+ writeStreamChunk(normalized);
496
+ streamedContent = full;
497
+ }
498
+ }
499
+ else if (event.type === "cancelled") {
500
+ stopThinking("cancelled-event");
501
+ isStreaming = false;
502
+ streamedContent = "";
503
+ streamLineBuffer = "";
504
+ inStreamCodeBlock = false;
505
+ streamIsFirstLine = true;
506
+ }
507
+ else if (event.type === "message") {
508
+ debugLog("stream-message", {
509
+ requestId: activeRequestId,
510
+ isStreaming,
511
+ contentLength: typeof event.content === "string" ? event.content.length : 0,
512
+ });
513
+ if (isStreaming) {
514
+ // Streaming is done — flush remaining and re-prompt
515
+ flushStreamState();
516
+ isStreaming = false;
517
+ lastResponse = streamedContent;
518
+ streamedContent = "";
519
+ if (event.route && event.route.routerMode === "auto") {
520
+ const r = event.route;
521
+ const label = r.overrideName
522
+ ? `⚡ auto · ${r.model} (${r.overrideName})`
523
+ : `⚡ auto · ${r.model}`;
524
+ process.stdout.write(`\n${LABEL_PAD}${C.dim(label)}`);
525
+ }
526
+ process.stdout.write("\n\n\n");
527
+ }
528
+ else {
529
+ // Proactive/background message — render with label
530
+ stopThinking("message-event");
531
+ lastResponse = event.content;
532
+ const rendered = renderMarkdown(event.content);
533
+ process.stdout.write("\n");
534
+ writeLabeled("max", rendered);
535
+ process.stdout.write("\n\n");
536
+ }
537
+ activeRequestStartedAt = 0;
538
+ rl.prompt();
539
+ }
540
+ }
541
+ catch (err) {
542
+ debugLog("sse-event-parse-error", {
543
+ linePreview: previewForDebug(line),
544
+ error: err instanceof Error ? err.message : String(err),
545
+ });
546
+ // Malformed event, ignore
547
+ }
548
+ }
549
+ }
550
+ });
551
+ res.on("end", () => {
552
+ stopThinking("sse-end");
553
+ debugLog("sse-end");
554
+ console.log(C.yellow("\n ⚠ disconnected — reconnecting..."));
555
+ isStreaming = false;
556
+ streamedContent = "";
557
+ setTimeout(connectSSE, 2000);
558
+ });
559
+ res.on("error", (err) => {
560
+ stopThinking("sse-error");
561
+ debugLog("sse-error", { error: err.message });
562
+ console.error(C.red(`\n ✗ connection error — retrying...`));
563
+ isStreaming = false;
564
+ streamedContent = "";
565
+ setTimeout(connectSSE, 3000);
566
+ });
567
+ }).on("error", (err) => {
568
+ debugLog("sse-connect-error", { error: err.message });
569
+ console.error(C.red(` ✗ cannot connect to daemon`));
570
+ console.error(C.dim(" start with: max start"));
571
+ setTimeout(connectSSE, 5000);
572
+ });
573
+ }
574
+ // ── API helpers ───────────────────────────────────────────
575
+ function sendMessage(prompt, requestId) {
576
+ const body = JSON.stringify({ prompt, connectionId });
577
+ const url = new URL("/message", API_BASE);
578
+ debugLog("message-send-start", {
579
+ requestId,
580
+ promptLength: prompt.length,
581
+ connectionId: connectionId || null,
582
+ });
583
+ const req = http.request(url, {
584
+ method: "POST",
585
+ headers: {
586
+ "Content-Type": "application/json",
587
+ "Content-Length": Buffer.byteLength(body),
588
+ ...authHeaders(),
589
+ },
590
+ }, (res) => {
591
+ let data = "";
592
+ res.on("data", (chunk) => (data += chunk));
593
+ res.on("end", () => {
594
+ debugLog("message-send-end", {
595
+ requestId,
596
+ statusCode: res.statusCode || null,
597
+ responseLength: data.length,
598
+ responsePreview: previewForDebug(data),
599
+ });
600
+ if (res.statusCode !== 200) {
601
+ stopThinking("message-post-error");
602
+ console.error(C.red(` Error: ${data}`));
603
+ rl.prompt();
604
+ }
605
+ });
606
+ });
607
+ req.on("error", (err) => {
608
+ stopThinking("message-request-error");
609
+ debugLog("message-send-error", { requestId, error: err.message });
610
+ console.error(C.red(` Failed to send: ${err.message}`));
611
+ rl.prompt();
612
+ });
613
+ req.write(body);
614
+ req.end();
615
+ debugLog("message-send-dispatched", { requestId, byteLength: Buffer.byteLength(body) });
616
+ }
617
+ /** Silent GET — no re-prompt (used for startup info) */
618
+ function apiGetSilent(path, cb) {
619
+ const url = new URL(path, API_BASE);
620
+ http.get(url, { headers: authHeaders() }, (res) => {
621
+ let data = "";
622
+ res.on("data", (chunk) => (data += chunk));
623
+ res.on("end", () => {
624
+ try {
625
+ cb(JSON.parse(data));
626
+ }
627
+ catch { /* ignore */ }
628
+ });
629
+ }).on("error", () => { cb(null); });
630
+ }
631
+ /** GET a JSON endpoint and call back with parsed result. */
632
+ function apiGet(path, cb) {
633
+ const url = new URL(path, API_BASE);
634
+ http.get(url, { headers: authHeaders() }, (res) => {
635
+ let data = "";
636
+ res.on("data", (chunk) => (data += chunk));
637
+ res.on("end", () => {
638
+ try {
639
+ cb(JSON.parse(data));
640
+ }
641
+ catch {
642
+ console.log(data);
643
+ }
644
+ rl.prompt();
645
+ });
646
+ }).on("error", (err) => {
647
+ console.error(C.red(` Error: ${err.message}`));
648
+ rl.prompt();
649
+ });
650
+ }
651
+ /** POST a JSON endpoint and call back with parsed result. */
652
+ function apiPost(path, body, cb) {
653
+ const json = JSON.stringify(body);
654
+ const url = new URL(path, API_BASE);
655
+ const req = http.request(url, {
656
+ method: "POST",
657
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(json), ...authHeaders() },
658
+ }, (res) => {
659
+ let data = "";
660
+ res.on("data", (chunk) => (data += chunk));
661
+ res.on("end", () => {
662
+ try {
663
+ cb(JSON.parse(data));
664
+ }
665
+ catch {
666
+ console.log(data);
667
+ }
668
+ rl.prompt();
669
+ });
670
+ });
671
+ req.on("error", (err) => {
672
+ console.error(C.red(` Error: ${err.message}`));
673
+ rl.prompt();
674
+ });
675
+ req.write(json);
676
+ req.end();
677
+ }
678
+ /** DELETE an endpoint and call back with parsed result. */
679
+ function apiDelete(path, cb) {
680
+ const url = new URL(path, API_BASE);
681
+ const req = http.request(url, {
682
+ method: "DELETE",
683
+ headers: authHeaders(),
684
+ }, (res) => {
685
+ let data = "";
686
+ res.on("data", (chunk) => (data += chunk));
687
+ res.on("end", () => {
688
+ try {
689
+ cb(JSON.parse(data));
690
+ }
691
+ catch {
692
+ console.log(data);
693
+ }
694
+ rl.prompt();
695
+ });
696
+ });
697
+ req.on("error", (err) => {
698
+ console.error(C.red(` Error: ${err.message}`));
699
+ rl.prompt();
700
+ });
701
+ req.end();
702
+ }
703
+ function sendCancel() {
704
+ stopThinking("user-cancel");
705
+ debugLog("cancel-send", { requestId: activeRequestId, isStreaming });
706
+ const url = new URL("/cancel", API_BASE);
707
+ const req = http.request(url, { method: "POST", headers: authHeaders() }, (res) => {
708
+ let data = "";
709
+ res.on("data", (chunk) => (data += chunk));
710
+ res.on("end", () => {
711
+ if (isStreaming)
712
+ process.stdout.write("\n");
713
+ isStreaming = false;
714
+ streamedContent = "";
715
+ console.log(C.dim(" ⛔ cancelled\n"));
716
+ rl.prompt();
717
+ });
718
+ });
719
+ req.on("error", (err) => {
720
+ console.error(C.red(` Failed to cancel: ${err.message}`));
721
+ rl.prompt();
722
+ });
723
+ req.end();
724
+ }
725
+ // ── Command handlers ──────────────────────────────────────
726
+ function cmdWorkers() {
727
+ apiGet("/sessions", (sessions) => {
728
+ if (!sessions || sessions.length === 0) {
729
+ console.log(C.dim(" No active worker sessions.\n"));
730
+ }
731
+ else {
732
+ for (const s of sessions) {
733
+ const badge = s.status === "idle" ? C.green("● idle") : C.yellow("● busy");
734
+ console.log(` ${badge} ${C.bold(s.name)} ${C.dim(s.workingDir)}`);
735
+ }
736
+ console.log();
737
+ }
738
+ });
739
+ }
740
+ function cmdModel(arg) {
741
+ if (arg) {
742
+ apiPost("/model", { model: arg }, (data) => {
743
+ if (data.error) {
744
+ console.log(C.red(` Error: ${data.error}\n`));
745
+ }
746
+ else {
747
+ console.log(` ${C.dim("model:")} ${C.dim(data.previous)} → ${C.cyan(data.current)}\n`);
748
+ }
749
+ });
750
+ }
751
+ else {
752
+ apiGet("/model", (data) => {
753
+ console.log(` ${C.dim("model:")} ${C.cyan(data.model)}\n`);
754
+ });
755
+ }
756
+ }
757
+ function cmdMemory() {
758
+ apiGet("/memory", (memories) => {
759
+ if (!memories || memories.length === 0) {
760
+ console.log(C.dim(" No memories stored.\n"));
761
+ }
762
+ else {
763
+ for (const m of memories) {
764
+ const cat = C.magenta(`[${m.category}]`);
765
+ console.log(` ${C.dim(`#${m.id}`)} ${cat} ${m.content}`);
766
+ }
767
+ console.log(C.dim(`\n ${memories.length} memories total.\n`));
768
+ }
769
+ });
770
+ }
771
+ function cmdSkills() {
772
+ apiGet("/skills", (skills) => {
773
+ if (!skills || skills.length === 0) {
774
+ console.log(C.dim(" No skills installed.\n"));
775
+ return;
776
+ }
777
+ // Build table
778
+ const localSkills = [];
779
+ console.log();
780
+ console.log(` ${C.boldWhite("#")} ${C.boldWhite("Skill")}${" ".repeat(24)}${C.boldWhite("Source")} ${C.boldWhite("Description")}`);
781
+ console.log(C.dim(" " + "─".repeat(72)));
782
+ for (let i = 0; i < skills.length; i++) {
783
+ const s = skills[i];
784
+ const num = String(i + 1).padStart(2);
785
+ const name = s.name.padEnd(28).slice(0, 28);
786
+ const src = s.source === "bundled" ? C.dim("bundled")
787
+ : s.source === "local" ? C.green("local")
788
+ : C.cyan("global");
789
+ const srcPad = s.source.padEnd(10);
790
+ const desc = (s.description || "").slice(0, 40);
791
+ if (s.source === "local") {
792
+ localSkills.push({ idx: i + 1, slug: s.slug });
793
+ console.log(` ${C.cyan(num)} ${name} ${src}${" ".repeat(Math.max(0, 10 - s.source.length))} ${C.dim(desc)}`);
794
+ }
795
+ else {
796
+ console.log(` ${C.dim(num)} ${name} ${src}${" ".repeat(Math.max(0, 10 - s.source.length))} ${C.dim(desc)}`);
797
+ }
798
+ }
799
+ console.log();
800
+ if (localSkills.length === 0) {
801
+ console.log(C.dim(" No local skills to uninstall.\n"));
802
+ return;
803
+ }
804
+ console.log(C.dim(` Type a number to uninstall a local skill, or press Enter to go back.`));
805
+ rl.question(` ${C.coral("uninstall #")} `, (answer) => {
806
+ const trimmed = answer.trim();
807
+ if (!trimmed) {
808
+ console.log();
809
+ rl.prompt();
810
+ return;
811
+ }
812
+ const num = /^\d+$/.test(trimmed) ? parseInt(trimmed, 10) : NaN;
813
+ const match = localSkills.find((s) => s.idx === num);
814
+ if (!match) {
815
+ console.log(C.yellow(` Invalid selection. Only local skills (highlighted) can be uninstalled.\n`));
816
+ rl.prompt();
817
+ return;
818
+ }
819
+ apiDelete(`/skills/${encodeURIComponent(match.slug)}`, (data) => {
820
+ if (data.error) {
821
+ console.log(C.red(` Error: ${data.error}\n`));
822
+ }
823
+ else {
824
+ console.log(C.green(` ✓ Removed '${match.slug}'\n`));
825
+ }
826
+ });
827
+ });
828
+ });
829
+ }
830
+ function cmdAuto() {
831
+ apiGetSilent("/auto", (data) => {
832
+ if (!data) {
833
+ rl.prompt();
834
+ return;
835
+ }
836
+ const newState = !data.enabled;
837
+ apiPost("/auto", { enabled: newState }, () => {
838
+ const label = newState
839
+ ? `${C.green("⚡")} auto on`
840
+ : `auto off · using ${C.cyan(data.currentModel)}`;
841
+ console.log(` ${label}\n`);
842
+ });
843
+ });
844
+ }
845
+ function cmdHelp() {
846
+ console.log();
847
+ console.log(C.boldWhite(" COMMANDS"));
848
+ console.log();
849
+ console.log(` ${C.coral("/model")} ${C.dim("[name]")} show or switch model`);
850
+ console.log(` ${C.coral("/auto")} toggle auto model routing`);
851
+ console.log(` ${C.coral("/memory")} show stored memories`);
852
+ console.log(` ${C.coral("/skills")} list installed skills`);
853
+ console.log(` ${C.coral("/workers")} list active sessions`);
854
+ console.log(` ${C.coral("/copy")} copy last response`);
855
+ console.log(` ${C.coral("/status")} daemon health check`);
856
+ console.log(` ${C.coral("/restart")} restart daemon`);
857
+ console.log(` ${C.coral("/clear")} clear screen`);
858
+ console.log(` ${C.coral("/quit")} exit`);
859
+ console.log();
860
+ console.log(C.dim(" press escape to cancel a running response"));
861
+ console.log(C.dim(" set MAX_TUI_DEBUG=1 to write lifecycle logs to ~/.max/tui-debug.log"));
862
+ console.log();
863
+ }
864
+ // ── Main ──────────────────────────────────────────────────
865
+ showBanner();
866
+ console.log(C.dim(" connecting..."));
867
+ connectSSE();
868
+ // Wait a moment for SSE connection before showing prompt
869
+ setTimeout(() => {
870
+ rl.prompt();
871
+ // Listen for Escape key to cancel in-flight messages
872
+ if (process.stdin.isTTY) {
873
+ process.stdin.on("keypress", (_str, key) => {
874
+ if (key && key.name === "escape") {
875
+ sendCancel();
876
+ }
877
+ });
878
+ }
879
+ rl.on("line", (line) => {
880
+ const trimmed = line.trim();
881
+ if (!trimmed) {
882
+ debugLog("input-empty-line");
883
+ rl.prompt();
884
+ return;
885
+ }
886
+ debugLog("input-line", {
887
+ length: trimmed.length,
888
+ isCommand: trimmed.startsWith("/"),
889
+ preview: previewForDebug(trimmed),
890
+ });
891
+ // Save to persistent history (skip commands)
892
+ if (!trimmed.startsWith("/")) {
893
+ saveHistoryLine(trimmed);
894
+ // Re-echo user input with YOU label, accounting for terminal wrapping
895
+ const cols = process.stdout.columns || 80;
896
+ const promptVisualLen = 4; // " › " is 4 visible chars
897
+ const inputVisualLen = promptVisualLen + trimmed.length;
898
+ const wrappedLines = Math.ceil(Math.max(inputVisualLen, 1) / cols);
899
+ // Move up enough lines to cover all wrapped lines
900
+ if (wrappedLines > 1) {
901
+ process.stdout.write(`\x1b[${wrappedLines}A\r\x1b[J`);
902
+ }
903
+ else {
904
+ process.stdout.write(`\x1b[1A\r\x1b[J`);
905
+ }
906
+ // Print with YOU label, wrapping long text with LABEL_PAD
907
+ const label = ` ${C.coral("YOU")} `;
908
+ const contentWidth = cols - 10; // 10 = label visual width
909
+ if (contentWidth > 0 && trimmed.length > contentWidth) {
910
+ const lines = [];
911
+ for (let i = 0; i < trimmed.length; i += contentWidth) {
912
+ lines.push(trimmed.slice(i, i + contentWidth));
913
+ }
914
+ for (let i = 0; i < lines.length; i++) {
915
+ console.log((i === 0 ? label : LABEL_PAD) + lines[i]);
916
+ }
917
+ }
918
+ else {
919
+ console.log(label + trimmed);
920
+ }
921
+ debugLog("input-rendered-you-label", {
922
+ columns: cols,
923
+ wrappedLines,
924
+ contentWidth,
925
+ });
926
+ }
927
+ if (trimmed === "/quit" || trimmed === "/exit") {
928
+ trimHistoryFile();
929
+ console.log(C.dim("\n bye.\n"));
930
+ process.exit(0);
931
+ }
932
+ if (trimmed === "/cancel") {
933
+ sendCancel();
934
+ return;
935
+ }
936
+ if (trimmed === "/sessions" || trimmed === "/workers") {
937
+ cmdWorkers();
938
+ return;
939
+ }
940
+ if (trimmed.startsWith("/model")) {
941
+ cmdModel(trimmed.slice(6).trim());
942
+ return;
943
+ }
944
+ if (trimmed === "/auto") {
945
+ cmdAuto();
946
+ return;
947
+ }
948
+ if (trimmed === "/memory") {
949
+ cmdMemory();
950
+ return;
951
+ }
952
+ if (trimmed === "/skills") {
953
+ cmdSkills();
954
+ return;
955
+ }
956
+ if (trimmed === "/help") {
957
+ cmdHelp();
958
+ return;
959
+ }
960
+ if (trimmed === "/status") {
961
+ apiGet("/status", (data) => {
962
+ console.log(JSON.stringify(data, null, 2) + "\n");
963
+ });
964
+ return;
965
+ }
966
+ if (trimmed === "/restart") {
967
+ apiPost("/restart", {}, () => {
968
+ console.log(C.yellow(" ⏳ Max is restarting...\n"));
969
+ });
970
+ return;
971
+ }
972
+ if (trimmed === "/clear") {
973
+ console.clear();
974
+ rl.prompt();
975
+ return;
976
+ }
977
+ if (trimmed === "/copy") {
978
+ if (!lastResponse) {
979
+ console.log(C.dim(" No response to copy.\n"));
980
+ rl.prompt();
981
+ return;
982
+ }
983
+ const tryClipboard = (cmds, idx) => {
984
+ if (idx >= cmds.length) {
985
+ console.log(C.dim(" Clipboard tool not found (install xclip or xsel).\n"));
986
+ rl.prompt();
987
+ return;
988
+ }
989
+ const [cmd, args] = cmds[idx];
990
+ const proc = execFile(cmd, args, (err) => {
991
+ if (err) {
992
+ tryClipboard(cmds, idx + 1);
993
+ }
994
+ else {
995
+ console.log(C.dim(" ✓ Copied to clipboard.\n"));
996
+ rl.prompt();
997
+ }
998
+ });
999
+ proc.stdin?.write(lastResponse);
1000
+ proc.stdin?.end();
1001
+ };
1002
+ tryClipboard([
1003
+ ["pbcopy", []],
1004
+ ["xclip", ["-selection", "clipboard"]],
1005
+ ["xsel", ["--clipboard", "--input"]],
1006
+ ], 0);
1007
+ return;
1008
+ }
1009
+ // Send to orchestrator
1010
+ activeRequestId += 1;
1011
+ activeRequestStartedAt = Date.now();
1012
+ debugLog("request-dispatch", {
1013
+ requestId: activeRequestId,
1014
+ inputLength: trimmed.length,
1015
+ columns: process.stdout.columns || null,
1016
+ });
1017
+ startThinking();
1018
+ sendMessage(trimmed, activeRequestId);
1019
+ });
1020
+ rl.on("close", () => {
1021
+ trimHistoryFile();
1022
+ console.log(C.dim("\n bye.\n"));
1023
+ process.exit(0);
1024
+ });
1025
+ }, 1000);
1026
+ //# sourceMappingURL=index.js.map