arc402-cli 0.5.0 → 0.6.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/src/repl.ts CHANGED
@@ -1,23 +1,12 @@
1
- import readline from "node:readline";
2
1
  import chalk from "chalk";
3
2
  import fs from "fs";
4
3
  import path from "path";
5
4
  import os from "os";
6
5
  import { createProgram } from "./program";
7
- import { renderBanner, BannerConfig } from "./ui/banner";
6
+ import { getBannerLines, BannerConfig } from "./ui/banner";
8
7
  import { c } from "./ui/colors";
9
8
 
10
- // ─── Prompt ───────────────────────────────────────────────────────────────────
11
-
12
- const PROMPT =
13
- chalk.cyanBright("◈") +
14
- " " +
15
- chalk.dim("arc402") +
16
- " " +
17
- chalk.white(">") +
18
- " ";
19
-
20
- // ─── Sentinel thrown to intercept process.exit() from commands ───────────────
9
+ // ─── Sentinel to intercept process.exit() from commands ──────────────────────
21
10
 
22
11
  class REPLExitSignal extends Error {
23
12
  constructor(public readonly code: number = 0) {
@@ -66,75 +55,54 @@ async function loadBannerConfig(): Promise<BannerConfig | undefined> {
66
55
  }
67
56
  }
68
57
 
69
- // ─── Status dashboard ─────────────────────────────────────────────────────────
58
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
70
59
 
71
- async function showStatus(): Promise<void> {
72
- console.log();
73
- console.log(
74
- " " + chalk.cyanBright("◈") + " " + chalk.dim("─".repeat(45))
75
- );
76
- if (!fs.existsSync(CONFIG_PATH)) {
77
- console.log(
78
- chalk.dim(" No config found. Run 'config init' to get started.")
79
- );
80
- console.log();
81
- return;
82
- }
83
- try {
84
- const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as {
85
- network?: string;
86
- walletContractAddress?: string;
87
- rpcUrl?: string;
88
- };
89
- if (raw.network)
90
- console.log(` ${chalk.dim("Network")} ${chalk.white(raw.network)}`);
91
- if (raw.walletContractAddress) {
92
- const w = raw.walletContractAddress;
93
- console.log(
94
- ` ${chalk.dim("Wallet")} ${chalk.white(`${w.slice(0, 6)}...${w.slice(-4)}`)}`
95
- );
96
- }
97
- if (raw.rpcUrl && raw.walletContractAddress) {
98
- try {
99
- // eslint-disable-next-line @typescript-eslint/no-var-requires
100
- const ethersLib = require("ethers") as typeof import("ethers");
101
- const provider = new ethersLib.ethers.JsonRpcProvider(raw.rpcUrl);
102
- const bal = await Promise.race([
103
- provider.getBalance(raw.walletContractAddress),
104
- new Promise<never>((_, r) =>
105
- setTimeout(() => r(new Error("timeout")), 2000)
106
- ),
107
- ]);
108
- console.log(
109
- ` ${chalk.dim("Balance")} ${chalk.white(
110
- `${parseFloat(ethersLib.ethers.formatEther(bal)).toFixed(4)} ETH`
111
- )}`
112
- );
113
- } catch {
114
- /* skip */
115
- }
116
- }
117
- } catch {
118
- /* skip */
119
- }
120
- console.log();
60
+ const ESC = "\x1b";
61
+
62
+ const ansi = {
63
+ clearScreen: `${ESC}[2J`,
64
+ home: `${ESC}[H`,
65
+ clearLine: `${ESC}[2K`,
66
+ clearToEol: `${ESC}[K`,
67
+ hideCursor: `${ESC}[?25l`,
68
+ showCursor: `${ESC}[?25h`,
69
+ move: (r: number, col: number) => `${ESC}[${r};${col}H`,
70
+ scrollRegion: (top: number, bot: number) => `${ESC}[${top};${bot}r`,
71
+ resetScroll: `${ESC}[r`,
72
+ };
73
+
74
+ function write(s: string): void {
75
+ process.stdout.write(s);
121
76
  }
122
77
 
123
- // ─── Shell-style tokenizer (handles "quoted strings") ────────────────────────
78
+ // ─── Prompt constants ─────────────────────────────────────────────────────────
79
+
80
+ const PROMPT_TEXT =
81
+ chalk.cyanBright("◈") +
82
+ " " +
83
+ chalk.dim("arc402") +
84
+ " " +
85
+ chalk.white(">") +
86
+ " ";
87
+
88
+ // Visible character count of "◈ arc402 > "
89
+ const PROMPT_VIS = 11;
90
+
91
+ // ─── Known command detection ──────────────────────────────────────────────────
92
+
93
+ const BUILTIN_CMDS = ["help", "exit", "quit", "clear", "status"];
94
+
95
+ // ─── Shell-style tokenizer ────────────────────────────────────────────────────
124
96
 
125
97
  function parseTokens(input: string): string[] {
126
98
  const tokens: string[] = [];
127
99
  let current = "";
128
100
  let inQuote = false;
129
101
  let quoteChar = "";
130
-
131
102
  for (const ch of input) {
132
103
  if (inQuote) {
133
- if (ch === quoteChar) {
134
- inQuote = false;
135
- } else {
136
- current += ch;
137
- }
104
+ if (ch === quoteChar) inQuote = false;
105
+ else current += ch;
138
106
  } else if (ch === '"' || ch === "'") {
139
107
  inQuote = true;
140
108
  quoteChar = ch;
@@ -151,134 +119,407 @@ function parseTokens(input: string): string[] {
151
119
  return tokens;
152
120
  }
153
121
 
154
- // ─── Tab completer ────────────────────────────────────────────────────────────
122
+ // ─── Tab completion logic ─────────────────────────────────────────────────────
155
123
 
156
- function buildCompleter(
124
+ function getCompletions(
125
+ line: string,
157
126
  topCmds: string[],
158
127
  subCmds: Map<string, string[]>
159
- ): readline.Completer {
160
- const specialCmds = ["help", "exit", "quit", "clear", "status"];
161
- const allTop = [...specialCmds, ...topCmds];
162
-
163
- return function completer(line: string): [string[], string] {
164
- const trimmed = line.trimStart();
165
- const spaceIdx = trimmed.indexOf(" ");
166
-
167
- if (spaceIdx === -1) {
168
- // Completing the first word (top-level command)
169
- const hits = allTop.filter((cmd) => cmd.startsWith(trimmed));
170
- return [hits.length ? hits : allTop, trimmed];
171
- }
172
-
173
- // Completing a subcommand
174
- const parent = trimmed.slice(0, spaceIdx);
175
- const rest = trimmed.slice(spaceIdx + 1);
176
- const subs = subCmds.get(parent) ?? [];
177
- const hits = subs.filter((s) => s.startsWith(rest));
178
- return [
179
- hits.map((s) => `${parent} ${s}`),
180
- trimmed,
181
- ];
182
- };
128
+ ): string[] {
129
+ const allTop = [...BUILTIN_CMDS, ...topCmds];
130
+ const trimmed = line.trimStart();
131
+ const spaceIdx = trimmed.indexOf(" ");
132
+ if (spaceIdx === -1) {
133
+ return allTop.filter((cmd) => cmd.startsWith(trimmed));
134
+ }
135
+ const parent = trimmed.slice(0, spaceIdx);
136
+ const rest = trimmed.slice(spaceIdx + 1);
137
+ const subs = subCmds.get(parent) ?? [];
138
+ return subs.filter((s) => s.startsWith(rest)).map((s) => `${parent} ${s}`);
183
139
  }
184
140
 
185
- // ─── REPL entry point ─────────────────────────────────────────────────────────
141
+ // ─── TUI class ────────────────────────────────────────────────────────────────
142
+
143
+ class TUI {
144
+ private inputBuffer = "";
145
+ private cursorPos = 0;
146
+ private history: string[] = [];
147
+ private historyIdx = -1;
148
+ private historyTemp = "";
149
+ private bannerLines: string[] = [];
150
+ private topCmds: string[] = [];
151
+ private subCmds = new Map<string, string[]>();
152
+ private bannerCfg?: BannerConfig;
153
+ private commandRunning = false;
154
+
155
+ private get termRows(): number {
156
+ return process.stdout.rows || 24;
157
+ }
158
+ private get termCols(): number {
159
+ return process.stdout.columns || 80;
160
+ }
161
+ private get scrollTop(): number {
162
+ // +1 for separator row after banner
163
+ return this.bannerLines.length + 2;
164
+ }
165
+ private get scrollBot(): number {
166
+ return this.termRows - 1;
167
+ }
168
+ private get inputRow(): number {
169
+ return this.termRows;
170
+ }
186
171
 
187
- export async function startREPL(): Promise<void> {
188
- // Show the banner
189
- const bannerCfg = await loadBannerConfig();
190
- renderBanner(bannerCfg);
191
-
192
- // Build a template program once just to extract command metadata for completions
193
- const template = createProgram();
194
- const topCmds = template.commands.map((cmd) => cmd.name());
195
- const subCmds = new Map<string, string[]>();
196
- for (const cmd of template.commands) {
197
- if (cmd.commands.length > 0) {
198
- subCmds.set(
199
- cmd.name(),
200
- cmd.commands.map((s) => s.name())
201
- );
172
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
173
+
174
+ async start(): Promise<void> {
175
+ this.bannerCfg = await loadBannerConfig();
176
+
177
+ // Build command metadata for completion
178
+ const template = createProgram();
179
+ this.topCmds = template.commands.map((cmd) => cmd.name());
180
+ for (const cmd of template.commands) {
181
+ if (cmd.commands.length > 0) {
182
+ this.subCmds.set(
183
+ cmd.name(),
184
+ cmd.commands.map((s) => s.name())
185
+ );
186
+ }
187
+ }
188
+
189
+ // Draw initial screen
190
+ this.setupScreen();
191
+ this.drawInputLine();
192
+
193
+ // Enter raw mode
194
+ if (process.stdin.isTTY) {
195
+ process.stdin.setRawMode(true);
202
196
  }
197
+ process.stdin.resume();
198
+ process.stdin.setEncoding("utf8");
199
+ process.stdin.on("data", this.boundKeyHandler);
200
+
201
+ // Resize handler
202
+ process.stdout.on("resize", () => {
203
+ this.setupScreen();
204
+ this.drawInputLine();
205
+ });
206
+
207
+ // SIGINT (shouldn't fire in raw mode, but just in case)
208
+ process.on("SIGINT", () => this.exitGracefully());
209
+
210
+ // Keep alive — process.stdin listener keeps the event loop running
211
+ await new Promise<never>(() => {
212
+ /* never resolves; process.exit() is called on quit */
213
+ });
203
214
  }
204
215
 
205
- const rl = readline.createInterface({
206
- input: process.stdin,
207
- output: process.stdout,
208
- prompt: PROMPT,
209
- completer: buildCompleter(topCmds, subCmds),
210
- terminal: true,
211
- historySize: 200,
212
- });
213
-
214
- function goodbye(): void {
215
- console.log(
216
- "\n " + chalk.cyanBright("◈") + chalk.dim(" goodbye")
216
+ // ── Screen setup ────────────────────────────────────────────────────────────
217
+
218
+ private setupScreen(): void {
219
+ this.bannerLines = getBannerLines(this.bannerCfg);
220
+
221
+ write(ansi.hideCursor);
222
+ write(ansi.clearScreen + ansi.home);
223
+
224
+ // Banner
225
+ for (const line of this.bannerLines) {
226
+ write(line + "\n");
227
+ }
228
+
229
+ // Separator between banner and output area
230
+ write(chalk.dim("─".repeat(this.termCols)) + "\n");
231
+
232
+ // Set scroll region (output area, leaves last row free for input)
233
+ if (this.scrollTop <= this.scrollBot) {
234
+ write(ansi.scrollRegion(this.scrollTop, this.scrollBot));
235
+ }
236
+
237
+ // Position cursor at top of output area
238
+ write(ansi.move(this.scrollTop, 1));
239
+ write(ansi.showCursor);
240
+ }
241
+
242
+ // ── Banner repaint (in-place, preserves output area) ────────────────────────
243
+
244
+ private repaintBanner(): void {
245
+ write(ansi.hideCursor);
246
+ for (let i = 0; i < this.bannerLines.length; i++) {
247
+ write(ansi.move(i + 1, 1) + ansi.clearToEol + this.bannerLines[i]);
248
+ }
249
+ // Separator
250
+ const sepRow = this.bannerLines.length + 1;
251
+ write(
252
+ ansi.move(sepRow, 1) +
253
+ ansi.clearToEol +
254
+ chalk.dim("─".repeat(this.termCols))
217
255
  );
256
+ write(ansi.showCursor);
218
257
  }
219
258
 
220
- rl.on("SIGINT", () => {
221
- goodbye();
222
- rl.close();
223
- process.exit(0);
224
- });
259
+ // ── Input line ───────────────────────────────────────────────────────────────
225
260
 
226
- rl.on("close", () => {
227
- goodbye();
228
- process.exit(0);
229
- });
261
+ private drawInputLine(): void {
262
+ write(ansi.move(this.inputRow, 1) + ansi.clearLine);
263
+ write(PROMPT_TEXT + this.inputBuffer);
264
+ // Place cursor at correct position within the input
265
+ write(ansi.move(this.inputRow, PROMPT_VIS + 1 + this.cursorPos));
266
+ }
230
267
 
231
- rl.prompt();
268
+ // ── Key handler ──────────────────────────────────────────────────────────────
232
269
 
233
- // Process lines one at a time
234
- for await (const line of rl) {
235
- const input = line.trim();
270
+ private readonly boundKeyHandler = (key: string): void => {
271
+ if (this.commandRunning) return;
272
+ this.handleKey(key);
273
+ };
274
+
275
+ private handleKey(key: string): void {
276
+ // Ctrl+C
277
+ if (key === "\u0003") {
278
+ this.exitGracefully();
279
+ return;
280
+ }
281
+ // Ctrl+L — refresh
282
+ if (key === "\u000C") {
283
+ this.setupScreen();
284
+ this.drawInputLine();
285
+ return;
286
+ }
287
+ // Enter
288
+ if (key === "\r" || key === "\n") {
289
+ void this.submit();
290
+ return;
291
+ }
292
+ // Backspace
293
+ if (key === "\u007F" || key === "\b") {
294
+ if (this.cursorPos > 0) {
295
+ this.inputBuffer =
296
+ this.inputBuffer.slice(0, this.cursorPos - 1) +
297
+ this.inputBuffer.slice(this.cursorPos);
298
+ this.cursorPos--;
299
+ this.drawInputLine();
300
+ }
301
+ return;
302
+ }
303
+ // Delete (forward)
304
+ if (key === "\x1b[3~") {
305
+ if (this.cursorPos < this.inputBuffer.length) {
306
+ this.inputBuffer =
307
+ this.inputBuffer.slice(0, this.cursorPos) +
308
+ this.inputBuffer.slice(this.cursorPos + 1);
309
+ this.drawInputLine();
310
+ }
311
+ return;
312
+ }
313
+ // Up arrow — history prev
314
+ if (key === "\x1b[A") {
315
+ if (this.historyIdx === -1) {
316
+ this.historyTemp = this.inputBuffer;
317
+ this.historyIdx = this.history.length - 1;
318
+ } else if (this.historyIdx > 0) {
319
+ this.historyIdx--;
320
+ }
321
+ if (this.historyIdx >= 0) {
322
+ this.inputBuffer = this.history[this.historyIdx];
323
+ this.cursorPos = this.inputBuffer.length;
324
+ this.drawInputLine();
325
+ }
326
+ return;
327
+ }
328
+ // Down arrow — history next
329
+ if (key === "\x1b[B") {
330
+ if (this.historyIdx >= 0) {
331
+ this.historyIdx++;
332
+ if (this.historyIdx >= this.history.length) {
333
+ this.historyIdx = -1;
334
+ this.inputBuffer = this.historyTemp;
335
+ } else {
336
+ this.inputBuffer = this.history[this.historyIdx];
337
+ }
338
+ this.cursorPos = this.inputBuffer.length;
339
+ this.drawInputLine();
340
+ }
341
+ return;
342
+ }
343
+ // Right arrow
344
+ if (key === "\x1b[C") {
345
+ if (this.cursorPos < this.inputBuffer.length) {
346
+ this.cursorPos++;
347
+ this.drawInputLine();
348
+ }
349
+ return;
350
+ }
351
+ // Left arrow
352
+ if (key === "\x1b[D") {
353
+ if (this.cursorPos > 0) {
354
+ this.cursorPos--;
355
+ this.drawInputLine();
356
+ }
357
+ return;
358
+ }
359
+ // Home / Ctrl+A
360
+ if (key === "\x1b[H" || key === "\u0001") {
361
+ this.cursorPos = 0;
362
+ this.drawInputLine();
363
+ return;
364
+ }
365
+ // End / Ctrl+E
366
+ if (key === "\x1b[F" || key === "\u0005") {
367
+ this.cursorPos = this.inputBuffer.length;
368
+ this.drawInputLine();
369
+ return;
370
+ }
371
+ // Ctrl+U — clear line
372
+ if (key === "\u0015") {
373
+ this.inputBuffer = "";
374
+ this.cursorPos = 0;
375
+ this.drawInputLine();
376
+ return;
377
+ }
378
+ // Ctrl+K — kill to end
379
+ if (key === "\u000B") {
380
+ this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos);
381
+ this.drawInputLine();
382
+ return;
383
+ }
384
+ // Tab — completion
385
+ if (key === "\t") {
386
+ this.handleTab();
387
+ return;
388
+ }
389
+ // Printable characters
390
+ if (key >= " " && !key.startsWith("\x1b")) {
391
+ this.inputBuffer =
392
+ this.inputBuffer.slice(0, this.cursorPos) +
393
+ key +
394
+ this.inputBuffer.slice(this.cursorPos);
395
+ this.cursorPos += key.length;
396
+ this.drawInputLine();
397
+ }
398
+ }
399
+
400
+ // ── Tab completion ───────────────────────────────────────────────────────────
401
+
402
+ private handleTab(): void {
403
+ const completions = getCompletions(
404
+ this.inputBuffer,
405
+ this.topCmds,
406
+ this.subCmds
407
+ );
408
+ if (completions.length === 0) return;
409
+
410
+ if (completions.length === 1) {
411
+ this.inputBuffer = completions[0] + " ";
412
+ this.cursorPos = this.inputBuffer.length;
413
+ this.drawInputLine();
414
+ return;
415
+ }
416
+
417
+ // Find common prefix
418
+ const common = completions.reduce((a, b) => {
419
+ let i = 0;
420
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
421
+ return a.slice(0, i);
422
+ });
423
+ if (common.length > this.inputBuffer.trimStart().length) {
424
+ this.inputBuffer = common;
425
+ this.cursorPos = common.length;
426
+ }
427
+
428
+ // Show options in output area
429
+ this.writeOutput("\n" + chalk.dim(completions.join(" ")) + "\n");
430
+ this.drawInputLine();
431
+ }
432
+
433
+ // ── Write to output area ─────────────────────────────────────────────────────
434
+
435
+ private writeOutput(text: string): void {
436
+ // Move cursor to bottom of scroll region to ensure scroll-down works
437
+ write(ansi.move(this.scrollBot, 1));
438
+ write(text);
439
+ }
440
+
441
+ // ── Submit line ──────────────────────────────────────────────────────────────
442
+
443
+ private async submit(): Promise<void> {
444
+ const input = this.inputBuffer.trim();
445
+ this.inputBuffer = "";
446
+ this.cursorPos = 0;
447
+ this.historyIdx = -1;
236
448
 
237
449
  if (!input) {
238
- rl.prompt();
239
- continue;
450
+ this.drawInputLine();
451
+ return;
240
452
  }
241
453
 
242
- // ── Special built-in commands ──────────────────────────────────────────
454
+ // Add to history
455
+ if (input !== this.history[this.history.length - 1]) {
456
+ this.history.push(input);
457
+ }
458
+
459
+ // Echo the input into the output area
460
+ this.writeOutput(
461
+ "\n" + chalk.dim("◈ ") + chalk.white(input) + "\n"
462
+ );
463
+
464
+ // ── Built-in commands ──────────────────────────────────────────────────────
243
465
 
244
466
  if (input === "exit" || input === "quit") {
245
- goodbye();
246
- rl.close();
247
- process.exit(0);
467
+ this.exitGracefully();
468
+ return;
248
469
  }
249
470
 
250
471
  if (input === "clear") {
251
- console.clear();
252
- const cfg = await loadBannerConfig();
253
- renderBanner(cfg);
254
- rl.prompt();
255
- continue;
472
+ this.bannerCfg = await loadBannerConfig();
473
+ this.setupScreen();
474
+ this.drawInputLine();
475
+ return;
256
476
  }
257
477
 
258
478
  if (input === "status") {
259
- await showStatus();
260
- rl.prompt();
261
- continue;
262
- }
263
-
264
- if (input === "help" || input === "help ") {
265
- // Show the full commander help via the program
266
- const prog = createProgram();
267
- prog.exitOverride();
268
- prog.configureOutput({
269
- writeOut: (str) => process.stdout.write(str),
270
- writeErr: (str) => process.stderr.write(str),
271
- });
272
- try {
273
- await prog.parseAsync(["node", "arc402", "--help"]);
274
- } catch {
275
- /* commander throws after printing help — ignore */
479
+ await this.runStatus();
480
+ this.afterCommand();
481
+ return;
482
+ }
483
+
484
+ if (input === "help" || input === "/help") {
485
+ await this.runHelp();
486
+ this.afterCommand();
487
+ return;
488
+ }
489
+
490
+ // ── /chat prefix — explicit chat route ────────────────────────────────────
491
+
492
+ if (input.startsWith("/chat ") || input === "/chat") {
493
+ const msg = input.slice(6).trim();
494
+ if (msg) {
495
+ this.commandRunning = true;
496
+ await this.sendChat(msg);
497
+ this.commandRunning = false;
276
498
  }
277
- rl.prompt();
278
- continue;
499
+ this.afterCommand();
500
+ return;
501
+ }
502
+
503
+ // ── Chat mode detection ────────────────────────────────────────────────────
504
+
505
+ const firstWord = input.split(/\s+/)[0];
506
+ const allKnown = [...BUILTIN_CMDS, ...this.topCmds];
507
+ if (!allKnown.includes(firstWord)) {
508
+ this.commandRunning = true;
509
+ await this.sendChat(input);
510
+ this.commandRunning = false;
511
+ this.afterCommand();
512
+ return;
279
513
  }
280
514
 
281
- // ── Dispatch to commander ──────────────────────────────────────────────
515
+ // ── Dispatch to commander ──────────────────────────────────────────────────
516
+
517
+ this.commandRunning = true;
518
+ // Move output cursor to bottom of scroll region
519
+ write(ansi.move(this.scrollBot, 1));
520
+
521
+ // Suspend TUI stdin so interactive commands (prompts, readline) work cleanly
522
+ process.stdin.removeListener("data", this.boundKeyHandler);
282
523
 
283
524
  const tokens = parseTokens(input);
284
525
  const prog = createProgram();
@@ -288,7 +529,6 @@ export async function startREPL(): Promise<void> {
288
529
  writeErr: (str) => process.stderr.write(str),
289
530
  });
290
531
 
291
- // Intercept process.exit() so a command exiting doesn't kill the REPL
292
532
  const origExit = process.exit;
293
533
  (process as NodeJS.Process).exit = ((code?: number) => {
294
534
  throw new REPLExitSignal(code ?? 0);
@@ -298,23 +538,27 @@ export async function startREPL(): Promise<void> {
298
538
  await prog.parseAsync(["node", "arc402", ...tokens]);
299
539
  } catch (err) {
300
540
  if (err instanceof REPLExitSignal) {
301
- // Command called process.exit() — normal, just continue the REPL
541
+ // Command called process.exit() — normal
302
542
  } else {
303
543
  const e = err as { code?: string; message?: string };
304
544
  if (
305
545
  e.code === "commander.helpDisplayed" ||
306
546
  e.code === "commander.version"
307
547
  ) {
308
- // Help / version output was already written — nothing to do
548
+ // already written
309
549
  } else if (e.code === "commander.unknownCommand") {
310
- console.log(
311
- `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)}`
550
+ process.stdout.write(
551
+ `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)} \n`
552
+ );
553
+ process.stdout.write(
554
+ chalk.dim(" Type 'help' for available commands\n")
312
555
  );
313
- console.log(chalk.dim(" Type 'help' for available commands\n"));
314
556
  } else if (e.code?.startsWith("commander.")) {
315
- console.log(`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`);
557
+ process.stdout.write(
558
+ `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
559
+ );
316
560
  } else {
317
- console.log(
561
+ process.stdout.write(
318
562
  `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
319
563
  );
320
564
  }
@@ -323,6 +567,227 @@ export async function startREPL(): Promise<void> {
323
567
  (process as NodeJS.Process).exit = origExit;
324
568
  }
325
569
 
326
- rl.prompt();
570
+ // Restore raw mode + our listener (interactive commands may have toggled it)
571
+ if (process.stdin.isTTY) {
572
+ process.stdin.setRawMode(true);
573
+ }
574
+ process.stdin.on("data", this.boundKeyHandler);
575
+ this.commandRunning = false;
576
+
577
+ this.afterCommand();
578
+ }
579
+
580
+ // ── OpenClaw chat ─────────────────────────────────────────────────────────────
581
+
582
+ private async sendChat(message: string): Promise<void> {
583
+ write(ansi.move(this.scrollBot, 1));
584
+
585
+ let res: Response;
586
+ try {
587
+ res = await fetch("http://localhost:19000/api/agent", {
588
+ method: "POST",
589
+ headers: { "Content-Type": "application/json" },
590
+ body: JSON.stringify({ message, session: "arc402-repl" }),
591
+ signal: AbortSignal.timeout(30000),
592
+ });
593
+ } catch (err: unknown) {
594
+ const msg = err instanceof Error ? err.message : String(err);
595
+ const isDown =
596
+ msg.includes("ECONNREFUSED") ||
597
+ msg.includes("fetch failed") ||
598
+ msg.includes("ENOTFOUND") ||
599
+ msg.includes("UND_ERR_SOCKET");
600
+ if (isDown) {
601
+ process.stdout.write(
602
+ "\n " +
603
+ chalk.yellow("⚠") +
604
+ " " +
605
+ chalk.dim("OpenClaw gateway not running. Start with: ") +
606
+ chalk.white("openclaw gateway start") +
607
+ "\n"
608
+ );
609
+ } else {
610
+ process.stdout.write(
611
+ "\n " + c.failure + " " + chalk.red(msg) + "\n"
612
+ );
613
+ }
614
+ return;
615
+ }
616
+
617
+ if (!res.body) {
618
+ process.stdout.write(
619
+ "\n" + chalk.dim(" ◈ ") + chalk.white("(empty response)") + "\n"
620
+ );
621
+ return;
622
+ }
623
+
624
+ process.stdout.write("\n");
625
+
626
+ const flushLine = (line: string): void => {
627
+ // Unwrap SSE data lines
628
+ if (line.startsWith("data: ")) {
629
+ line = line.slice(6);
630
+ if (line === "[DONE]") return;
631
+ try {
632
+ const j = JSON.parse(line) as {
633
+ text?: string;
634
+ content?: string;
635
+ delta?: { text?: string };
636
+ };
637
+ line = j.text ?? j.content ?? j.delta?.text ?? line;
638
+ } catch {
639
+ /* use raw */
640
+ }
641
+ }
642
+ if (line.trim()) {
643
+ process.stdout.write(chalk.dim(" ◈ ") + chalk.white(line) + "\n");
644
+ }
645
+ };
646
+
647
+ const reader = res.body.getReader();
648
+ const decoder = new TextDecoder();
649
+ let buffer = "";
650
+
651
+ while (true) {
652
+ const { done, value } = await reader.read();
653
+ if (done) break;
654
+ buffer += decoder.decode(value, { stream: true });
655
+ const lines = buffer.split("\n");
656
+ buffer = lines.pop() ?? "";
657
+ for (const line of lines) flushLine(line);
658
+ }
659
+
660
+ if (buffer.trim()) flushLine(buffer);
661
+ }
662
+
663
+ // ── After each command: repaint banner + input ───────────────────────────────
664
+
665
+ private afterCommand(): void {
666
+ this.repaintBanner();
667
+ this.drawInputLine();
668
+ }
669
+
670
+ // ── Built-in: status ─────────────────────────────────────────────────────────
671
+
672
+ private async runStatus(): Promise<void> {
673
+ write(ansi.move(this.scrollBot, 1));
674
+ if (!fs.existsSync(CONFIG_PATH)) {
675
+ process.stdout.write(
676
+ chalk.dim("\n No config found. Run 'config init' to get started.\n")
677
+ );
678
+ return;
679
+ }
680
+ try {
681
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as {
682
+ network?: string;
683
+ walletContractAddress?: string;
684
+ rpcUrl?: string;
685
+ };
686
+ process.stdout.write("\n");
687
+ if (raw.network)
688
+ process.stdout.write(
689
+ ` ${chalk.dim("Network")} ${chalk.white(raw.network)}\n`
690
+ );
691
+ if (raw.walletContractAddress) {
692
+ const w = raw.walletContractAddress;
693
+ process.stdout.write(
694
+ ` ${chalk.dim("Wallet")} ${chalk.white(`${w.slice(0, 6)}...${w.slice(-4)}`)}\n`
695
+ );
696
+ }
697
+ if (raw.rpcUrl && raw.walletContractAddress) {
698
+ try {
699
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
700
+ const ethersLib = require("ethers") as typeof import("ethers");
701
+ const provider = new ethersLib.ethers.JsonRpcProvider(raw.rpcUrl);
702
+ const bal = await Promise.race([
703
+ provider.getBalance(raw.walletContractAddress),
704
+ new Promise<never>((_, r) =>
705
+ setTimeout(() => r(new Error("timeout")), 2000)
706
+ ),
707
+ ]);
708
+ process.stdout.write(
709
+ ` ${chalk.dim("Balance")} ${chalk.white(
710
+ `${parseFloat(ethersLib.ethers.formatEther(bal)).toFixed(4)} ETH`
711
+ )}\n`
712
+ );
713
+ } catch {
714
+ /* skip */
715
+ }
716
+ }
717
+ process.stdout.write("\n");
718
+ } catch {
719
+ /* skip */
720
+ }
327
721
  }
722
+
723
+ // ── Built-in: help ────────────────────────────────────────────────────────────
724
+
725
+ private async runHelp(): Promise<void> {
726
+ write(ansi.move(this.scrollBot, 1));
727
+ process.stdin.removeListener("data", this.boundKeyHandler);
728
+ const prog = createProgram();
729
+ prog.exitOverride();
730
+ prog.configureOutput({
731
+ writeOut: (str) => process.stdout.write(str),
732
+ writeErr: (str) => process.stderr.write(str),
733
+ });
734
+ try {
735
+ await prog.parseAsync(["node", "arc402", "--help"]);
736
+ } catch {
737
+ /* commander throws after printing help */
738
+ }
739
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
740
+ process.stdin.on("data", this.boundKeyHandler);
741
+
742
+ process.stdout.write("\n");
743
+ process.stdout.write(chalk.cyanBright("Chat") + "\n");
744
+ process.stdout.write(
745
+ " " +
746
+ chalk.white("<message>") +
747
+ chalk.dim(" Send message to OpenClaw gateway\n")
748
+ );
749
+ process.stdout.write(
750
+ " " +
751
+ chalk.white("/chat <message>") +
752
+ chalk.dim(" Explicitly route to chat\n")
753
+ );
754
+ process.stdout.write(
755
+ chalk.dim(
756
+ " Gateway: http://localhost:19000 (openclaw gateway start)\n"
757
+ )
758
+ );
759
+ process.stdout.write("\n");
760
+ }
761
+
762
+ // ── Exit ──────────────────────────────────────────────────────────────────────
763
+
764
+ private exitGracefully(): void {
765
+ write(ansi.move(this.inputRow, 1) + ansi.clearLine);
766
+ write(" " + chalk.cyanBright("◈") + chalk.dim(" goodbye") + "\n");
767
+ write(ansi.resetScroll);
768
+ write(ansi.showCursor);
769
+ if (process.stdin.isTTY) {
770
+ process.stdin.setRawMode(false);
771
+ }
772
+ process.exit(0);
773
+ }
774
+ }
775
+
776
+ // ─── REPL entry point ─────────────────────────────────────────────────────────
777
+
778
+ export async function startREPL(): Promise<void> {
779
+ if (!process.stdout.isTTY) {
780
+ // Non-TTY (piped): fall back to minimal line-mode output
781
+ const bannerCfg = await loadBannerConfig();
782
+ for (const line of getBannerLines(bannerCfg)) {
783
+ process.stdout.write(line + "\n");
784
+ }
785
+ process.stdout.write(
786
+ "Interactive TUI requires a TTY. Use arc402 <command> directly.\n"
787
+ );
788
+ return;
789
+ }
790
+
791
+ const tui = new TUI();
792
+ await tui.start();
328
793
  }