arc402-cli 0.5.0 → 1.0.0-rc.1

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,396 @@ 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);
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
+ });
214
+ }
215
+
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));
202
235
  }
236
+
237
+ // Position cursor at top of output area
238
+ write(ansi.move(this.scrollTop, 1));
239
+ write(ansi.showCursor);
203
240
  }
204
241
 
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")
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
+ }
267
+
268
+ // ── Key handler ──────────────────────────────────────────────────────────────
269
+
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
+ }
230
399
 
231
- rl.prompt();
400
+ // ── Tab completion ───────────────────────────────────────────────────────────
232
401
 
233
- // Process lines one at a time
234
- for await (const line of rl) {
235
- const input = line.trim();
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;
452
+ }
453
+
454
+ // Add to history
455
+ if (input !== this.history[this.history.length - 1]) {
456
+ this.history.push(input);
240
457
  }
241
458
 
242
- // ── Special built-in commands ──────────────────────────────────────────
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 */
276
- }
277
- rl.prompt();
278
- continue;
479
+ await this.runStatus();
480
+ this.afterCommand();
481
+ return;
482
+ }
483
+
484
+ if (input === "help") {
485
+ await this.runHelp();
486
+ this.afterCommand();
487
+ return;
488
+ }
489
+
490
+ // ── Chat mode detection ────────────────────────────────────────────────────
491
+
492
+ const firstWord = input.split(/\s+/)[0];
493
+ const allKnown = [...BUILTIN_CMDS, ...this.topCmds];
494
+ if (!allKnown.includes(firstWord)) {
495
+ this.writeOutput(
496
+ chalk.dim(
497
+ "\n ◈ Chat coming soon — type a command or help\n"
498
+ )
499
+ );
500
+ this.afterCommand();
501
+ return;
279
502
  }
280
503
 
281
- // ── Dispatch to commander ──────────────────────────────────────────────
504
+ // ── Dispatch to commander ──────────────────────────────────────────────────
505
+
506
+ this.commandRunning = true;
507
+ // Move output cursor to bottom of scroll region
508
+ write(ansi.move(this.scrollBot, 1));
509
+
510
+ // Suspend TUI stdin so interactive commands (prompts, readline) work cleanly
511
+ process.stdin.removeListener("data", this.boundKeyHandler);
282
512
 
283
513
  const tokens = parseTokens(input);
284
514
  const prog = createProgram();
@@ -288,7 +518,6 @@ export async function startREPL(): Promise<void> {
288
518
  writeErr: (str) => process.stderr.write(str),
289
519
  });
290
520
 
291
- // Intercept process.exit() so a command exiting doesn't kill the REPL
292
521
  const origExit = process.exit;
293
522
  (process as NodeJS.Process).exit = ((code?: number) => {
294
523
  throw new REPLExitSignal(code ?? 0);
@@ -298,23 +527,27 @@ export async function startREPL(): Promise<void> {
298
527
  await prog.parseAsync(["node", "arc402", ...tokens]);
299
528
  } catch (err) {
300
529
  if (err instanceof REPLExitSignal) {
301
- // Command called process.exit() — normal, just continue the REPL
530
+ // Command called process.exit() — normal
302
531
  } else {
303
532
  const e = err as { code?: string; message?: string };
304
533
  if (
305
534
  e.code === "commander.helpDisplayed" ||
306
535
  e.code === "commander.version"
307
536
  ) {
308
- // Help / version output was already written — nothing to do
537
+ // already written
309
538
  } else if (e.code === "commander.unknownCommand") {
310
- console.log(
311
- `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)}`
539
+ process.stdout.write(
540
+ `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)} \n`
541
+ );
542
+ process.stdout.write(
543
+ chalk.dim(" Type 'help' for available commands\n")
312
544
  );
313
- console.log(chalk.dim(" Type 'help' for available commands\n"));
314
545
  } else if (e.code?.startsWith("commander.")) {
315
- console.log(`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`);
546
+ process.stdout.write(
547
+ `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
548
+ );
316
549
  } else {
317
- console.log(
550
+ process.stdout.write(
318
551
  `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
319
552
  );
320
553
  }
@@ -323,6 +556,125 @@ export async function startREPL(): Promise<void> {
323
556
  (process as NodeJS.Process).exit = origExit;
324
557
  }
325
558
 
326
- rl.prompt();
559
+ // Restore raw mode + our listener (interactive commands may have toggled it)
560
+ if (process.stdin.isTTY) {
561
+ process.stdin.setRawMode(true);
562
+ }
563
+ process.stdin.on("data", this.boundKeyHandler);
564
+ this.commandRunning = false;
565
+
566
+ this.afterCommand();
567
+ }
568
+
569
+ // ── After each command: repaint banner + input ───────────────────────────────
570
+
571
+ private afterCommand(): void {
572
+ this.repaintBanner();
573
+ this.drawInputLine();
574
+ }
575
+
576
+ // ── Built-in: status ─────────────────────────────────────────────────────────
577
+
578
+ private async runStatus(): Promise<void> {
579
+ write(ansi.move(this.scrollBot, 1));
580
+ if (!fs.existsSync(CONFIG_PATH)) {
581
+ process.stdout.write(
582
+ chalk.dim("\n No config found. Run 'config init' to get started.\n")
583
+ );
584
+ return;
585
+ }
586
+ try {
587
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as {
588
+ network?: string;
589
+ walletContractAddress?: string;
590
+ rpcUrl?: string;
591
+ };
592
+ process.stdout.write("\n");
593
+ if (raw.network)
594
+ process.stdout.write(
595
+ ` ${chalk.dim("Network")} ${chalk.white(raw.network)}\n`
596
+ );
597
+ if (raw.walletContractAddress) {
598
+ const w = raw.walletContractAddress;
599
+ process.stdout.write(
600
+ ` ${chalk.dim("Wallet")} ${chalk.white(`${w.slice(0, 6)}...${w.slice(-4)}`)}\n`
601
+ );
602
+ }
603
+ if (raw.rpcUrl && raw.walletContractAddress) {
604
+ try {
605
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
606
+ const ethersLib = require("ethers") as typeof import("ethers");
607
+ const provider = new ethersLib.ethers.JsonRpcProvider(raw.rpcUrl);
608
+ const bal = await Promise.race([
609
+ provider.getBalance(raw.walletContractAddress),
610
+ new Promise<never>((_, r) =>
611
+ setTimeout(() => r(new Error("timeout")), 2000)
612
+ ),
613
+ ]);
614
+ process.stdout.write(
615
+ ` ${chalk.dim("Balance")} ${chalk.white(
616
+ `${parseFloat(ethersLib.ethers.formatEther(bal)).toFixed(4)} ETH`
617
+ )}\n`
618
+ );
619
+ } catch {
620
+ /* skip */
621
+ }
622
+ }
623
+ process.stdout.write("\n");
624
+ } catch {
625
+ /* skip */
626
+ }
627
+ }
628
+
629
+ // ── Built-in: help ────────────────────────────────────────────────────────────
630
+
631
+ private async runHelp(): Promise<void> {
632
+ write(ansi.move(this.scrollBot, 1));
633
+ process.stdin.removeListener("data", this.boundKeyHandler);
634
+ const prog = createProgram();
635
+ prog.exitOverride();
636
+ prog.configureOutput({
637
+ writeOut: (str) => process.stdout.write(str),
638
+ writeErr: (str) => process.stderr.write(str),
639
+ });
640
+ try {
641
+ await prog.parseAsync(["node", "arc402", "--help"]);
642
+ } catch {
643
+ /* commander throws after printing help */
644
+ }
645
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
646
+ process.stdin.on("data", this.boundKeyHandler);
647
+ }
648
+
649
+ // ── Exit ──────────────────────────────────────────────────────────────────────
650
+
651
+ private exitGracefully(): void {
652
+ write(ansi.move(this.inputRow, 1) + ansi.clearLine);
653
+ write(" " + chalk.cyanBright("◈") + chalk.dim(" goodbye") + "\n");
654
+ write(ansi.resetScroll);
655
+ write(ansi.showCursor);
656
+ if (process.stdin.isTTY) {
657
+ process.stdin.setRawMode(false);
658
+ }
659
+ process.exit(0);
660
+ }
661
+ }
662
+
663
+ // ─── REPL entry point ─────────────────────────────────────────────────────────
664
+
665
+ export async function startREPL(): Promise<void> {
666
+ if (!process.stdout.isTTY) {
667
+ // Non-TTY (piped): fall back to minimal line-mode output
668
+ const bannerCfg = await loadBannerConfig();
669
+ for (const line of getBannerLines(bannerCfg)) {
670
+ process.stdout.write(line + "\n");
671
+ }
672
+ process.stdout.write(
673
+ "Interactive TUI requires a TTY. Use arc402 <command> directly.\n"
674
+ );
675
+ return;
327
676
  }
677
+
678
+ const tui = new TUI();
679
+ await tui.start();
328
680
  }