llm-agent-cli 2.0.0 → 2.0.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/dist/index.js CHANGED
@@ -21,6 +21,30 @@ import { run_linter, run_tests, security_scan } from "./tools/linter.js";
21
21
  import { loadCredentials, resolveConnectionFromEnvOrCreds, runLogin, runLogout, runWhoami, } from "./auth.js";
22
22
  import { loadMemory, appendMemory, clearMemory, showMemory, } from "./memory.js";
23
23
  const PERMISSION_MODES = ["safe", "standard", "yolo"];
24
+ const UI = {
25
+ accent: chalk.hex("#D97757"),
26
+ info: chalk.cyanBright,
27
+ success: chalk.greenBright,
28
+ warning: chalk.yellowBright,
29
+ danger: chalk.redBright,
30
+ muted: chalk.gray,
31
+ title: chalk.bold.white,
32
+ };
33
+ const ICON = {
34
+ spark: "✦",
35
+ gear: "⚙",
36
+ shield: "🛡",
37
+ folder: "📁",
38
+ network: "🌐",
39
+ model: "🧠",
40
+ stack: "🧩",
41
+ session: "🗂",
42
+ tip: "💡",
43
+ status: "●",
44
+ command: "⌘",
45
+ token: "◉",
46
+ retry: "↻",
47
+ };
24
48
  function isPermissionMode(value) {
25
49
  return PERMISSION_MODES.includes(value);
26
50
  }
@@ -30,56 +54,161 @@ function isOutputFormat(value) {
30
54
  function formatUsd(value) {
31
55
  return `$${value.toFixed(4)}`;
32
56
  }
33
- function looksLikeMarkdown(text) {
34
- return /(^\s{0,3}#{1,6}\s|\*\*|`[^`]+`|^\s*[-*+]\s|\|.+\|)/m.test(text);
57
+ function stripAnsi(value) {
58
+ return value.replace(/\x1B\[[0-9;]*m/g, "");
59
+ }
60
+ function badge(label, tone = "info") {
61
+ const color = tone === "success"
62
+ ? UI.success
63
+ : tone === "warning"
64
+ ? UI.warning
65
+ : tone === "danger"
66
+ ? UI.danger
67
+ : tone === "muted"
68
+ ? UI.muted
69
+ : UI.info;
70
+ return color(`◈ ${label}`);
71
+ }
72
+ function fitCell(value, width) {
73
+ const plain = stripAnsi(value);
74
+ if (plain.length <= width) {
75
+ return `${value}${" ".repeat(width - plain.length)}`;
76
+ }
77
+ return `${plain.slice(0, Math.max(0, width - 1))}…`;
78
+ }
79
+ function fitInline(value, width) {
80
+ const plain = stripAnsi(value);
81
+ if (plain.length <= width) {
82
+ return `${value}${" ".repeat(width - plain.length)}`;
83
+ }
84
+ return `${plain.slice(0, Math.max(0, width - 1))}…`;
85
+ }
86
+ function renderPanel(lines) {
87
+ const minWidth = 72;
88
+ const maxWidth = 112;
89
+ const dynamicWidth = Math.max(minWidth, Math.min(maxWidth, (processStdout.columns ?? 100) - 6));
90
+ const innerWidth = dynamicWidth;
91
+ const top = UI.accent(`╭${"─".repeat(innerWidth)}╮`);
92
+ const body = lines.map((line) => `${UI.accent("│")}${fitInline(line, innerWidth)}${UI.accent("│")}`);
93
+ const bottom = UI.accent(`╰${"─".repeat(innerWidth)}╯`);
94
+ return [top, ...body, bottom].join("\n");
95
+ }
96
+ function printTable(headers, rows, widths) {
97
+ const divider = widths.map((w) => "─".repeat(w)).join(" ");
98
+ const headerLine = headers
99
+ .map((header, idx) => fitCell(UI.title(header), widths[idx] ?? 20))
100
+ .join(" ");
101
+ const bodyLines = rows.map((row) => row
102
+ .map((cell, idx) => fitCell(cell, widths[idx] ?? 20))
103
+ .join(" "));
104
+ return [headerLine, UI.muted(divider), ...bodyLines].join("\n");
105
+ }
106
+ function contextBar(ratio, width = 18) {
107
+ const clamped = Math.max(0, Math.min(1, ratio));
108
+ const filled = Math.round(clamped * width);
109
+ const bar = `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
110
+ if (clamped >= 0.9)
111
+ return UI.danger(bar);
112
+ if (clamped >= 0.7)
113
+ return UI.warning(bar);
114
+ return UI.success(bar);
35
115
  }
36
116
  function printBanner(runtime, permissionMode) {
37
117
  const model = process.env.LLM_ROUTER_MODEL ?? "auto";
38
118
  const baseUrl = process.env.LLM_ROUTER_BASE_URL ?? "(not set)";
39
- console.log(chalk.bold.cyan(`\n ╔══════════════════════════════════════╗\n ║ LLM-Router CLI Agent ║\n ║ Powered by your local LLM-Router ║\n ╚══════════════════════════════════════╝`));
40
- console.log(chalk.dim(` Router: ${baseUrl} | Model: ${model}`));
41
- console.log(chalk.dim(` Session: ${runtime.sessionId}`));
42
- console.log(chalk.dim(` Workspace: ${runtime.project.workspaceRoot}`));
43
- console.log(chalk.dim(` Permissions: ${permissionMode}`));
44
- console.log(chalk.dim(` Type /help for commands`));
45
- console.log();
119
+ const permissionTone = permissionMode === "safe"
120
+ ? "success"
121
+ : permissionMode === "yolo"
122
+ ? "danger"
123
+ : "warning";
124
+ const connectionTone = baseUrl.includes("localhost") ? "info" : "success";
125
+ const headerLine = `${UI.title(`${ICON.spark} LLM-Router CLI Agent`)} ${badge("interactive", "muted")} ${badge(permissionMode, permissionTone)} ${badge("v2", "muted")}`;
126
+ const subtitleLine = UI.muted("Claude-style terminal workflow with streaming tool execution");
127
+ const summaryLeft = `${ICON.network} ${UI.muted("Router")} ${UI.info(baseUrl)}`;
128
+ const summaryRight = `${ICON.model} ${UI.muted("Model")} ${UI.success(model)}`;
129
+ const workspaceLine = `${ICON.folder} ${UI.muted("Workspace")} ${runtime.project.workspaceRoot}`;
130
+ const stackLine = `${ICON.stack} ${UI.muted("Stack")} ${runtime.project.language} • ${runtime.project.framework} • ${runtime.project.packageManager}`;
131
+ const sessionLine = `${ICON.session} ${UI.muted("Session")} ${runtime.sessionId}`;
132
+ const statusLine = `${UI.muted(ICON.status)} ${badge("ready", connectionTone)} ${badge("drop files to include", "muted")}`;
133
+ const panel = renderPanel([
134
+ ` ${headerLine}`,
135
+ ` ${subtitleLine}`,
136
+ ` ${summaryLeft}`,
137
+ ` ${summaryRight}`,
138
+ ` ${workspaceLine}`,
139
+ ` ${stackLine}`,
140
+ ` ${sessionLine}`,
141
+ ` ${statusLine}`,
142
+ ]);
143
+ console.log("");
144
+ console.log(panel);
145
+ console.log("");
146
+ console.log(UI.muted(`${ICON.tip} Quick start`));
147
+ console.log(UI.muted(` ${ICON.command} /help ${ICON.command} /tools ${ICON.command} /models ${ICON.command} /cost ${ICON.command} /compact`));
148
+ console.log("");
149
+ }
150
+ function printCommandGroup(title, commands) {
151
+ console.log(UI.accent.bold(` ${title}`));
152
+ console.log(printTable(["Command", "Description"], commands.map(([cmd, desc]) => [UI.info(cmd), desc]), [28, 70]));
153
+ console.log("");
46
154
  }
47
155
  function printHelp() {
48
- console.log(chalk.bold("\nSlash commands:"));
49
- console.log(chalk.cyan(" /help") + " — show commands");
50
- console.log(chalk.cyan(" /exit") + " — quit");
51
- console.log(chalk.cyan(" /clear") + " clear terminal");
52
- console.log(chalk.cyan(" /compact") + " summarize and compact conversation");
53
- console.log(chalk.cyan(" /models") + " list router models");
54
- console.log(chalk.cyan(" /cost") + " show token/cost totals");
55
- console.log(chalk.cyan(" /save [name]") + " save named session snapshot");
56
- console.log(chalk.cyan(" /load [name]") + " load named session or list names");
57
- console.log(chalk.cyan(" /retry") + " — resend last user prompt");
58
- console.log(chalk.cyan(" /undo") + " remove last user+assistant turn");
59
- console.log(chalk.cyan(" /cd <path>") + " change working directory (within workspace)");
60
- console.log(chalk.cyan(" /tools") + " list tools");
61
- console.log(chalk.cyan(" /permissions") + " show permission mode");
62
- console.log(chalk.cyan(" /verbose") + " toggle verbose tool output");
63
- console.log(chalk.cyan(" /review [path]") + " code review a file or directory");
64
- console.log(chalk.cyan(" /scan [path]") + " — security scan (secrets, XSS, SQLi, etc.)");
65
- console.log(chalk.cyan(" /lint [path]") + " — run project linter");
66
- console.log(chalk.cyan(" /test [path]") + " run test suite");
67
- console.log(chalk.cyan(" /memory") + " show persistent memory (global + project)");
68
- console.log(chalk.cyan(" /remember <text>") + " append a fact to project memory");
69
- console.log(chalk.cyan(" /remember --global <text>") + " append to global memory");
70
- console.log(chalk.cyan(" /memory clear") + " — clear project memory");
71
- console.log(chalk.cyan(" /memory clear --global") + " — clear global memory");
156
+ console.log("");
157
+ console.log(renderPanel([` ${UI.title(`${ICON.command} Command Palette`)}`]));
158
+ console.log("");
159
+ printCommandGroup("🧭 Core", [
160
+ ["/help", "Show this help"],
161
+ ["/exit", "Quit the CLI"],
162
+ ["/clear", "Clear terminal output only"],
163
+ ["/retry", "Resend last prompt"],
164
+ ["/undo", "Remove last user+assistant turn"],
165
+ ]);
166
+ printCommandGroup("🗂 Session", [
167
+ ["/compact", "Summarize and compress conversation"],
168
+ ["/save [name]", "Save named session snapshot"],
169
+ ["/load [name]", "Load named session or list saved names"],
170
+ ["/models", "List available router models"],
171
+ ["/cost", "Show token and cost totals"],
172
+ ]);
173
+ printCommandGroup("📁 Workspace", [
174
+ ["/cd <path>", "Change working directory (within workspace root)"],
175
+ ["/tools", "List tools and descriptions"],
176
+ ["/permissions", "Show current permission mode"],
177
+ ["/verbose", "Toggle verbose tool result output"],
178
+ ]);
179
+ printCommandGroup("🧪 Quality", [
180
+ ["/review [path]", "Run a structured code review prompt"],
181
+ ["/lint [path]", "Run linter (auto detects toolchain)"],
182
+ ["/test [path]", "Run tests (auto detects runner)"],
183
+ ["/scan [path]", "Run security scan"],
184
+ ]);
185
+ printCommandGroup("🔑 Auth", [
186
+ ["/login", "Log in (opens browser for Google/GitHub auth)"],
187
+ ["/logout", "Log out and clear credentials"],
188
+ ["/whoami", "Show current account and router URL"],
189
+ ]);
190
+ printCommandGroup("🧠 Memory", [
191
+ ["/memory", "Show global + project memory"],
192
+ ["/memory clear", "Clear project memory"],
193
+ ["/memory clear --global", "Clear global memory"],
194
+ ["/remember <text>", "Remember fact in project memory"],
195
+ ["/remember --global <text>", "Remember fact in global memory"],
196
+ ]);
72
197
  console.log();
73
- console.log(chalk.bold("Multiline input:"));
74
- console.log(chalk.dim(" End a line with \\ to continue onto next line."));
75
- console.log(chalk.dim(" Type <<< then paste lines; end with a single . line."));
198
+ console.log(UI.accent.bold(" Input Modes"));
199
+ console.log(UI.muted(" End a line with \\ for multiline continuation"));
200
+ console.log(UI.muted(" Type <<< then paste blocks; finish with a single . line"));
76
201
  console.log();
77
202
  }
78
203
  function printTools() {
79
- console.log(chalk.bold("\nAvailable tools:"));
80
- for (const tool of listAvailableTools()) {
81
- console.log(` ${chalk.yellow(tool.name)} — ${tool.description}`);
82
- }
204
+ const tools = listAvailableTools();
205
+ const rows = tools.map((tool) => [
206
+ `${ICON.gear} ${UI.info(tool.name)}`,
207
+ tool.description,
208
+ ]);
209
+ console.log("");
210
+ console.log(UI.accent.bold(`${ICON.gear} Tools (${tools.length})`));
211
+ console.log(printTable(["Name", "Description"], rows, [30, 68]));
83
212
  console.log();
84
213
  }
85
214
  function modelFlag(model, key) {
@@ -120,22 +249,26 @@ function formatModels(models) {
120
249
  const rows = models.map((model) => ({
121
250
  id: String(model.id ?? ""),
122
251
  tier: String(model.tier ?? ""),
123
- owner: String(model.owned_by ?? ""),
252
+ provider: String(model.provider ?? model.owned_by ?? ""),
253
+ context: Number(model.contextWindow ?? model.context_window ?? 0),
124
254
  caps: extractModelCapabilities(model),
255
+ isBlocked: Boolean(model.isBlocked ?? model.is_blocked ?? false),
125
256
  }));
126
- const headers = ["ID", "TIER", "OWNER", "CAPABILITIES"];
127
- const widths = [40, 10, 12, 45];
128
- const line = (values) => values
129
- .map((value, idx) => {
130
- const width = widths[idx] ?? 20;
131
- if (value.length <= width)
132
- return value.padEnd(width, " ");
133
- return `${value.slice(0, Math.max(0, width - 1))}…`;
134
- })
135
- .join(" ");
136
- const divider = widths.map((w) => "-".repeat(w)).join(" ");
137
- const body = rows.map((row) => line([row.id, row.tier, row.owner, row.caps]));
138
- return [line(headers), divider, ...body].join("\n");
257
+ const tierText = (tier) => {
258
+ if (tier === "strong" || tier === "reasoning")
259
+ return UI.success(tier);
260
+ if (tier === "cheap")
261
+ return UI.warning(tier);
262
+ return UI.info(tier || "unknown");
263
+ };
264
+ return printTable(["Model", "Tier", "Provider", "Context", "Capabilities", "Status"], rows.map((row) => [
265
+ `${ICON.model} ${row.id}`,
266
+ tierText(row.tier),
267
+ row.provider || "-",
268
+ row.context > 0 ? `${Math.round(row.context / 1000)}k` : "-",
269
+ row.caps || "-",
270
+ row.isBlocked ? UI.warning("blocked") : UI.success("active"),
271
+ ]), [35, 10, 14, 9, 32, 10]);
139
272
  }
140
273
  async function readAllStdin() {
141
274
  const chunks = [];
@@ -210,6 +343,56 @@ async function bootstrapRuntime(options) {
210
343
  async function persistSnapshot(runtime) {
211
344
  await runtime.sessions.appendSnapshot(runtime.sessionId, runtime.agent.exportState(runtime.context.serializeState()));
212
345
  }
346
+ /**
347
+ * Turn a raw tool name + args JSON into a concise, human-readable action label.
348
+ * e.g. "read_file" + '{"path":"src/index.ts"}' → "Reading src/index.ts"
349
+ */
350
+ function humanizeToolAction(toolName, argsPreview) {
351
+ // Try to extract a path or key arg from the preview
352
+ let target = "";
353
+ try {
354
+ const parsed = JSON.parse(argsPreview.replace(/\.\.\.$/, ""));
355
+ target = parsed.path || parsed.file_path || parsed.pattern || parsed.command || parsed.url || parsed.directory || "";
356
+ }
357
+ catch {
358
+ // argsPreview may be truncated / not valid JSON — try to extract a path-like string
359
+ const pathMatch = argsPreview.match(/["']?([^\s"',{}]+\.[a-z]{1,6})["']?/i)
360
+ || argsPreview.match(/["']?([^\s"',{}]{3,60})["']?/);
361
+ target = pathMatch?.[1] ?? "";
362
+ }
363
+ // Shorten long paths to last 2 segments
364
+ if (target.length > 50) {
365
+ const parts = target.split("/");
366
+ target = parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : target.slice(-47) + "…";
367
+ }
368
+ const suffix = target ? ` ${UI.info(target)}` : "";
369
+ const verbs = {
370
+ read_file: "Reading",
371
+ write_to_file: "Writing",
372
+ apply_diff: "Editing",
373
+ list_directory: "Listing",
374
+ create_directory: "Creating directory",
375
+ delete_file: "Deleting",
376
+ move_file: "Moving",
377
+ get_file_info: "Checking",
378
+ search_files: "Searching",
379
+ find_files: "Finding files",
380
+ show_diff: "Diffing",
381
+ search_and_replace_all: "Replacing in",
382
+ fetch_url: "Fetching",
383
+ run_bash_command: "Running",
384
+ git_status: "Git status",
385
+ git_diff: "Git diff",
386
+ git_log: "Git log",
387
+ git_blame: "Git blame",
388
+ git_stash: "Git stash",
389
+ run_linter: "Linting",
390
+ run_tests: "Testing",
391
+ security_scan: "Scanning",
392
+ };
393
+ const verb = verbs[toolName] ?? toolName;
394
+ return `${verb}${suffix}`;
395
+ }
213
396
  async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutput) {
214
397
  const preprocessed = await preprocessPrompt(rawPrompt, Math.min(runtime.config.maxReadBytes, 16_000));
215
398
  if (preprocessed.includedFiles.length > 0) {
@@ -231,7 +414,12 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
231
414
  process.stderr.write(`warning: ${warning}\n`);
232
415
  }
233
416
  }
234
- const spinner = ora({ text: "Thinking...", color: "cyan" }).start();
417
+ const turnStartedAt = Date.now();
418
+ const spinner = ora({
419
+ text: `${ICON.spark} thinking...`,
420
+ color: "cyan",
421
+ spinner: "dots",
422
+ }).start();
235
423
  let printedToken = false;
236
424
  let printedAgentPrefix = false;
237
425
  let toolTimer = null;
@@ -251,37 +439,51 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
251
439
  spinner.stop();
252
440
  clearToolTimer();
253
441
  if (!printedAgentPrefix) {
254
- processStdout.write(chalk.bold.white("Agent: "));
442
+ processStdout.write(UI.accent.bold(`${ICON.spark} Assistant `));
255
443
  printedAgentPrefix = true;
256
444
  }
257
445
  printedToken = true;
258
446
  processStdout.write(token);
259
447
  },
260
- onRetry: async (attempt, delayMs) => {
261
- spinner.text = `Retry ${attempt}/3 in ${Math.floor(delayMs / 1000)}s...`;
448
+ onRetry: async (attempt, delayMs, reason) => {
449
+ const normalizedReason = reason.replace(/\s+/g, " ").trim();
450
+ const shortReason = normalizedReason.length > 72
451
+ ? `${normalizedReason.slice(0, 69)}...`
452
+ : normalizedReason;
453
+ spinner.text = `${ICON.retry} retry ${attempt}/3 in ${Math.floor(delayMs / 1000)}s (${shortReason})`;
262
454
  },
263
455
  onToolStart: async (event) => {
264
456
  if (!streamOutput)
265
457
  return;
266
458
  clearToolTimer();
267
459
  toolStartAt = Date.now();
268
- currentToolLabel = `${event.toolName} ${event.argsPreview}`;
269
- spinner.start(`> ${currentToolLabel} (0s)`);
460
+ currentToolLabel = humanizeToolAction(event.toolName, event.argsPreview);
461
+ spinner.start(`${chalk.dim(currentToolLabel)} (0.0s)`);
270
462
  toolTimer = setInterval(() => {
271
- const elapsed = Math.floor((Date.now() - toolStartAt) / 1000);
272
- spinner.text = `> ${currentToolLabel} (${elapsed}s)`;
273
- }, 1000);
463
+ const elapsed = ((Date.now() - toolStartAt) / 1000).toFixed(1);
464
+ spinner.text = `${chalk.dim(currentToolLabel)} (${elapsed}s)`;
465
+ }, 200);
274
466
  },
275
467
  onToolEnd: async (event) => {
276
468
  if (!streamOutput)
277
469
  return;
278
470
  clearToolTimer();
471
+ const elapsed = ((Date.now() - toolStartAt) / 1000).toFixed(1);
279
472
  spinner.stop();
473
+ const label = humanizeToolAction(event.toolName, "");
474
+ console.log(UI.muted(` ${UI.success("✓")} ${label}${elapsed !== "0.0" ? ` (${elapsed}s)` : ""}`));
280
475
  if (runtime.agent.isVerboseTools()) {
281
- console.log(chalk.dim(`\n[${event.toolName}]\n${event.resultPreview}`));
476
+ console.log(UI.muted(` ${event.resultPreview.split("\n").join("\n ")}`));
282
477
  }
283
478
  else if (event.truncated) {
284
- console.log(chalk.yellow(`\nTool ${event.toolName} output was truncated before returning to model.`));
479
+ console.log(UI.warning(` Output truncated before returning to model.`));
480
+ }
481
+ // Restart spinner so the user sees progress while the agent processes
482
+ // tool results and calls the router again.
483
+ if (event.index === event.total) {
484
+ printedToken = false;
485
+ printedAgentPrefix = false;
486
+ spinner.start(`${ICON.spark} thinking...`);
285
487
  }
286
488
  },
287
489
  };
@@ -295,15 +497,11 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
295
497
  const responseText = result.response;
296
498
  if (streamOutput) {
297
499
  if (!printedToken) {
298
- console.log(chalk.bold.white("Agent:"));
500
+ console.log(UI.accent.bold(`${ICON.spark} Assistant`));
299
501
  console.log(renderMarkdown(responseText).trimEnd());
300
502
  }
301
503
  else {
302
504
  processStdout.write("\n");
303
- if (looksLikeMarkdown(responseText)) {
304
- console.log(chalk.dim("[Rendered Markdown]"));
305
- console.log(renderMarkdown(responseText).trimEnd());
306
- }
307
505
  }
308
506
  }
309
507
  if (!streamOutput && outputFormat === "text") {
@@ -311,16 +509,24 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
311
509
  }
312
510
  if (result.usage) {
313
511
  const metrics = runtime.context.recordTurn(result.model, result.usage);
512
+ const turnMs = Date.now() - turnStartedAt;
314
513
  if (streamOutput) {
315
- console.log(chalk.dim(`[↑ ${metrics.promptTokens.toLocaleString()} | ↓ ${metrics.completionTokens.toLocaleString()} | ~${formatUsd(metrics.turnCostUsd)} | total ${formatUsd(metrics.cumulativeCostUsd)}]`));
514
+ console.log(UI.muted([
515
+ `ctx ${contextBar(metrics.contextUtilization)} ${(metrics.contextUtilization * 100).toFixed(1)}%`,
516
+ `${ICON.token} in ${metrics.promptTokens.toLocaleString()}`,
517
+ `${ICON.token} out ${metrics.completionTokens.toLocaleString()}`,
518
+ `turn ${formatUsd(metrics.turnCostUsd)}`,
519
+ `total ${formatUsd(metrics.cumulativeCostUsd)}`,
520
+ `${(turnMs / 1000).toFixed(1)}s`,
521
+ ].join(" | ")));
316
522
  }
317
523
  if (metrics.warnThresholdCrossed && streamOutput) {
318
- console.log(chalk.yellow(`Context usage warning: ${(metrics.contextUtilization * 100).toFixed(1)}% of configured window (${runtime.config.contextWindowTokens.toLocaleString()} tokens).`));
524
+ console.log(UI.warning(`Context usage warning: ${(metrics.contextUtilization * 100).toFixed(1)}% of configured window (${runtime.config.contextWindowTokens.toLocaleString()} tokens).`));
319
525
  }
320
526
  if (metrics.autoCompactRecommended) {
321
527
  const summary = await runtime.agent.compactHistory(signal);
322
528
  if (streamOutput) {
323
- console.log(chalk.yellow(`Auto-compact triggered at ${(metrics.contextUtilization * 100).toFixed(1)}% context usage.`));
529
+ console.log(UI.warning(`Auto-compact triggered at ${(metrics.contextUtilization * 100).toFixed(1)}% context usage.`));
324
530
  console.log(renderMarkdown(summary).trimEnd());
325
531
  }
326
532
  }
@@ -357,23 +563,58 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
357
563
  return { handled: true, shouldExit: true };
358
564
  case "/clear":
359
565
  console.clear();
566
+ printBanner(runtime, runtime.agent.getPermissionMode());
360
567
  return { handled: true, shouldExit: false };
361
568
  case "/tools":
362
569
  printTools();
363
570
  return { handled: true, shouldExit: false };
364
- case "/permissions":
365
- console.log(`Current permission mode: ${runtime.agent.getPermissionMode()}`);
571
+ case "/permissions": {
572
+ const mode = runtime.agent.getPermissionMode();
573
+ const policyRows = mode === "safe"
574
+ ? [
575
+ ["Writes", UI.danger("disabled")],
576
+ ["Shell", UI.danger("disabled")],
577
+ ["Approvals", UI.success("n/a")],
578
+ ]
579
+ : mode === "standard"
580
+ ? [
581
+ ["Writes", UI.warning("requires confirmation")],
582
+ ["Shell", UI.warning("requires confirmation")],
583
+ ["Approvals", UI.info("interactive")],
584
+ ]
585
+ : [
586
+ ["Writes", UI.success("enabled")],
587
+ ["Shell", UI.success("enabled")],
588
+ ["Approvals", UI.warning("auto-approved")],
589
+ ];
590
+ console.log("");
591
+ console.log(UI.accent.bold(`${ICON.shield} Permission Mode`));
592
+ console.log(printTable(["Setting", "Value"], policyRows, [18, 28]));
593
+ console.log(UI.muted(`Current mode: ${mode}`));
594
+ console.log("");
366
595
  return { handled: true, shouldExit: false };
596
+ }
367
597
  case "/verbose": {
368
598
  const next = !runtime.agent.isVerboseTools();
369
599
  runtime.agent.setVerboseTools(next);
370
- console.log(`Verbose tool output: ${next ? "ON" : "OFF"}`);
600
+ console.log(next
601
+ ? UI.success("Verbose tool output: ON")
602
+ : UI.muted("Verbose tool output: OFF"));
371
603
  await persistSnapshot(runtime);
372
604
  return { handled: true, shouldExit: false };
373
605
  }
374
606
  case "/cost": {
375
607
  const summary = runtime.context.getCostSummary();
376
- console.log(`${chalk.dim("input")}: ${summary.inputTokens.toLocaleString()} ${chalk.dim("output")}: ${summary.outputTokens.toLocaleString()} ${chalk.dim("total")}: ${summary.totalTokens.toLocaleString()} ${chalk.dim("cost")}: ${formatUsd(summary.totalCostUsd)}`);
608
+ const rows = [
609
+ ["Input tokens", summary.inputTokens.toLocaleString()],
610
+ ["Output tokens", summary.outputTokens.toLocaleString()],
611
+ ["Total tokens", summary.totalTokens.toLocaleString()],
612
+ ["Estimated cost", formatUsd(summary.totalCostUsd)],
613
+ ];
614
+ console.log("");
615
+ console.log(UI.accent.bold(`${ICON.token} Session Cost`));
616
+ console.log(printTable(["Metric", "Value"], rows, [20, 22]));
617
+ console.log("");
377
618
  return { handled: true, shouldExit: false };
378
619
  }
379
620
  case "/compact": {
@@ -384,18 +625,21 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
384
625
  }
385
626
  case "/models": {
386
627
  const models = await runtime.agent.listModels();
628
+ console.log("");
629
+ console.log(UI.accent.bold(`${ICON.model} Models (${models.length})`));
387
630
  console.log(formatModels(models));
631
+ console.log("");
388
632
  return { handled: true, shouldExit: false };
389
633
  }
390
634
  case "/undo": {
391
635
  const undone = runtime.agent.undoLastTurn();
392
636
  if (undone) {
393
637
  setLastRawPrompt(undone);
394
- console.log("Removed last user+assistant turn.");
638
+ console.log(UI.success("Removed last user+assistant turn."));
395
639
  await persistSnapshot(runtime);
396
640
  }
397
641
  else {
398
- console.log("Nothing to undo.");
642
+ console.log(UI.muted("Nothing to undo."));
399
643
  }
400
644
  return { handled: true, shouldExit: false };
401
645
  }
@@ -404,17 +648,21 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
404
648
  case "/save": {
405
649
  const name = argText || `session-${new Date().toISOString().replace(/[:.]/g, "-")}`;
406
650
  const location = await runtime.sessions.saveNamed(name, runtime.agent.exportState(runtime.context.serializeState()));
407
- console.log(`Saved session as \"${name}\" -> ${location}`);
651
+ console.log(UI.success(`Saved session "${name}"`));
652
+ console.log(UI.muted(`Path: ${location}`));
408
653
  return { handled: true, shouldExit: false };
409
654
  }
410
655
  case "/load": {
411
656
  if (!argText) {
412
657
  const names = await runtime.sessions.listNamed();
413
658
  if (names.length === 0) {
414
- console.log("No named sessions found.");
659
+ console.log(UI.muted("No named sessions found."));
415
660
  }
416
661
  else {
417
- console.log(`Named sessions: ${names.join(", ")}`);
662
+ console.log("");
663
+ console.log(UI.accent.bold(`Named Sessions (${names.length})`));
664
+ console.log(names.map((name) => ` ${UI.info(name)}`).join("\n"));
665
+ console.log("");
418
666
  }
419
667
  return { handled: true, shouldExit: false };
420
668
  }
@@ -425,7 +673,7 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
425
673
  }
426
674
  await restoreSnapshot(runtime, loaded);
427
675
  setLastRawPrompt(loaded.lastUserInput);
428
- console.log(`Loaded session \"${argText}\".`);
676
+ console.log(UI.success(`Loaded session "${argText}".`));
429
677
  await persistSnapshot(runtime);
430
678
  return { handled: true, shouldExit: false };
431
679
  }
@@ -441,7 +689,7 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
441
689
  return { handled: true, shouldExit: false };
442
690
  }
443
691
  process.chdir(path.resolve(resolved));
444
- console.log(`Working directory: ${process.cwd()}`);
692
+ console.log(UI.success(`Working directory: ${process.cwd()}`));
445
693
  await persistSnapshot(runtime);
446
694
  return { handled: true, shouldExit: false };
447
695
  }
@@ -451,11 +699,14 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
451
699
  try {
452
700
  const result = await run_linter({ path: targetPath, linter: "auto", fix: false });
453
701
  spinner.stop();
702
+ console.log("");
703
+ console.log(UI.accent.bold(`Lint Results — ${targetPath}`));
454
704
  console.log(result);
705
+ console.log("");
455
706
  }
456
707
  catch (err) {
457
708
  spinner.stop();
458
- console.log(chalk.red(`Linter error: ${err instanceof Error ? err.message : String(err)}`));
709
+ console.log(UI.danger(`Linter error: ${err instanceof Error ? err.message : String(err)}`));
459
710
  }
460
711
  return { handled: true, shouldExit: false };
461
712
  }
@@ -465,11 +716,14 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
465
716
  try {
466
717
  const result = await run_tests({ path: targetPath, runner: "auto", bail: false });
467
718
  spinner.stop();
719
+ console.log("");
720
+ console.log(UI.accent.bold(`Test Results${targetPath ? ` — ${targetPath}` : ""}`));
468
721
  console.log(result);
722
+ console.log("");
469
723
  }
470
724
  catch (err) {
471
725
  spinner.stop();
472
- console.log(chalk.red(`Test runner error: ${err instanceof Error ? err.message : String(err)}`));
726
+ console.log(UI.danger(`Test runner error: ${err instanceof Error ? err.message : String(err)}`));
473
727
  }
474
728
  return { handled: true, shouldExit: false };
475
729
  }
@@ -479,11 +733,14 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
479
733
  try {
480
734
  const result = await security_scan({ path: targetPath, include_low_severity: false });
481
735
  spinner.stop();
736
+ console.log("");
737
+ console.log(UI.accent.bold(`Security Scan — ${targetPath}`));
482
738
  console.log(result);
739
+ console.log("");
483
740
  }
484
741
  catch (err) {
485
742
  spinner.stop();
486
- console.log(chalk.red(`Scan error: ${err instanceof Error ? err.message : String(err)}`));
743
+ console.log(UI.danger(`Scan error: ${err instanceof Error ? err.message : String(err)}`));
487
744
  }
488
745
  return { handled: true, shouldExit: false };
489
746
  }
@@ -493,7 +750,10 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
493
750
  // /memory clear --global — clear global memory
494
751
  if (!argText) {
495
752
  const output = await showMemory(runtime.workspaceRoot);
753
+ console.log("");
754
+ console.log(UI.accent.bold("Memory"));
496
755
  console.log(output);
756
+ console.log("");
497
757
  return { handled: true, shouldExit: false };
498
758
  }
499
759
  const memArgs = argText.split(/\s+/);
@@ -502,17 +762,17 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
502
762
  await clearMemory(scope, runtime.workspaceRoot);
503
763
  const fresh = await loadMemory(runtime.workspaceRoot);
504
764
  runtime.agent.updateMemory(fresh);
505
- console.log(`${scope === "global" ? "Global" : "Project"} memory cleared.`);
765
+ console.log(UI.success(`${scope === "global" ? "Global" : "Project"} memory cleared.`));
506
766
  return { handled: true, shouldExit: false };
507
767
  }
508
- console.log(`Unknown /memory subcommand: ${argText}`);
768
+ console.log(UI.warning(`Unknown /memory subcommand: ${argText}`));
509
769
  return { handled: true, shouldExit: false };
510
770
  }
511
771
  case "/remember": {
512
772
  // /remember <text> — append to project memory
513
773
  // /remember --global <text> — append to global memory
514
774
  if (!argText) {
515
- console.log("Usage: /remember <text> or /remember --global <text>");
775
+ console.log(UI.warning("Usage: /remember <text> or /remember --global <text>"));
516
776
  return { handled: true, shouldExit: false };
517
777
  }
518
778
  let scope = "project";
@@ -522,14 +782,43 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
522
782
  fact = fact.slice("--global".length).trim();
523
783
  }
524
784
  if (!fact) {
525
- console.log("Nothing to remember — provide text after the flag.");
785
+ console.log(UI.warning("Nothing to remember — provide text after the flag."));
526
786
  return { handled: true, shouldExit: false };
527
787
  }
528
788
  const savedPath = await appendMemory(fact, scope, runtime.workspaceRoot);
529
789
  const fresh = await loadMemory(runtime.workspaceRoot);
530
790
  runtime.agent.updateMemory(fresh);
531
- console.log(chalk.green(`Remembered (${scope}): ${fact}`));
532
- console.log(chalk.dim(` ${savedPath}`));
791
+ console.log(UI.success(`Remembered (${scope}): ${fact}`));
792
+ console.log(UI.muted(`Path: ${savedPath}`));
793
+ return { handled: true, shouldExit: false };
794
+ }
795
+ case "/logout": {
796
+ try {
797
+ await runLogout();
798
+ console.log(UI.success("Logged out. Run /login or restart to switch accounts."));
799
+ }
800
+ catch (err) {
801
+ console.log(UI.danger(`Logout error: ${err instanceof Error ? err.message : String(err)}`));
802
+ }
803
+ return { handled: true, shouldExit: false };
804
+ }
805
+ case "/login": {
806
+ const routerUrl = process.env.LLM_ROUTER_BASE_URL || "";
807
+ try {
808
+ await runLogin({ routerUrl });
809
+ // Reload credentials into env so the agent uses the new account immediately
810
+ const conn = await resolveConnectionFromEnvOrCreds();
811
+ if (conn) {
812
+ console.log(UI.success("Logged in. New credentials active for this session."));
813
+ }
814
+ }
815
+ catch (err) {
816
+ console.log(UI.danger(`Login error: ${err instanceof Error ? err.message : String(err)}`));
817
+ }
818
+ return { handled: true, shouldExit: false };
819
+ }
820
+ case "/whoami": {
821
+ await runWhoami();
533
822
  return { handled: true, shouldExit: false };
534
823
  }
535
824
  case "/review": {
@@ -554,7 +843,7 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
554
843
  }
555
844
  catch (err) {
556
845
  if (!(err instanceof Error && err.name === "TurnCancelledError")) {
557
- console.log(chalk.red(`Review error: ${err instanceof Error ? err.message : String(err)}`));
846
+ console.log(UI.danger(`Review error: ${err instanceof Error ? err.message : String(err)}`));
558
847
  }
559
848
  }
560
849
  return { handled: true, shouldExit: false };
@@ -575,10 +864,16 @@ async function runNonInteractive(runtime, options) {
575
864
  await executeTurn(runtime, promptText, controller.signal, options.outputFormat, false);
576
865
  }
577
866
  async function runInteractive(runtime, options) {
867
+ _isInteractiveSession = true;
578
868
  printBanner(runtime, options.permissions);
579
869
  const health = await checkRouterHealth();
580
870
  if (!health.ok) {
581
- console.log(chalk.yellow(`Warning: ${health.message} check LLM_ROUTER_BASE_URL before running requests.`));
871
+ console.log(UI.warning(`${ICON.status} connection warning`));
872
+ console.log(UI.warning(` ${health.message} — check LLM_ROUTER_BASE_URL.`));
873
+ console.log();
874
+ }
875
+ else {
876
+ console.log(UI.success(`${ICON.status} router connection healthy.`));
582
877
  console.log();
583
878
  }
584
879
  const replInput = new ReplInput(runtime.paths.historyFile, runtime.config.historyLimit);
@@ -602,7 +897,25 @@ async function runInteractive(runtime, options) {
602
897
  });
603
898
  });
604
899
  while (true) {
605
- const userInput = await replInput.prompt();
900
+ // ora spinners write ANSI codes to stdout while readline manages the same
901
+ // stream, which can leave the readline interface in a closed/corrupt state
902
+ // (especially on Node.js 22+). Always reset after a turn so the next
903
+ // prompt() call starts with a fresh, healthy interface.
904
+ let userInput;
905
+ try {
906
+ userInput = await replInput.prompt();
907
+ }
908
+ catch {
909
+ try {
910
+ await replInput.resetInterface();
911
+ userInput = await replInput.prompt();
912
+ }
913
+ catch {
914
+ // Truly unrecoverable (e.g. stdin closed / Ctrl-D); exit gracefully.
915
+ console.log(chalk.yellow("\nSession ended."));
916
+ return;
917
+ }
918
+ }
606
919
  const trimmed = userInput.trim();
607
920
  if (!trimmed)
608
921
  continue;
@@ -617,18 +930,19 @@ async function runInteractive(runtime, options) {
617
930
  }
618
931
  if (trimmed === "/retry") {
619
932
  if (!lastRawPrompt) {
620
- console.log("No previous prompt to retry.");
933
+ console.log(UI.warning("No previous prompt to retry."));
621
934
  continue;
622
935
  }
623
936
  }
624
937
  else {
625
- console.log(`Unknown command: ${trimmed}`);
938
+ console.log(UI.warning(`Unknown command: ${trimmed}`));
939
+ console.log(UI.muted(`${ICON.tip} Run /help to view available commands.`));
626
940
  continue;
627
941
  }
628
942
  }
629
943
  const rawPrompt = trimmed === "/retry" ? lastRawPrompt ?? "" : userInput;
630
944
  if (!rawPrompt.trim()) {
631
- console.log("No previous prompt to retry.");
945
+ console.log(UI.warning("No previous prompt to retry."));
632
946
  continue;
633
947
  }
634
948
  lastRawPrompt = rawPrompt;
@@ -642,31 +956,38 @@ async function runInteractive(runtime, options) {
642
956
  }
643
957
  else {
644
958
  const message = err instanceof Error ? err.message : String(err);
645
- console.log(chalk.red(`Error: ${message}`));
959
+ console.log(UI.danger(`Error: ${message}`));
646
960
  }
647
961
  }
648
962
  finally {
649
963
  activeTurnController = null;
650
964
  }
651
965
  console.log();
966
+ // Reset readline interface after every turn so ora spinner side-effects
967
+ // don't accumulate and corrupt the next prompt() call.
968
+ await replInput.resetInterface();
652
969
  }
653
970
  await replInput.close();
654
971
  }
655
972
  // ── First-run setup wizard ────────────────────────────────────────────────────
656
973
  async function runFirstTimeSetup() {
657
- const { confirm, input, select } = await import("@inquirer/prompts");
658
- console.log(chalk.bold.cyan("\n Welcome to LLM-Router CLI Agent!\n"));
659
- console.log(chalk.dim(" No connection configured. Let's get you set up.\n"));
974
+ const { input, select } = await import("@inquirer/prompts");
975
+ console.log("");
976
+ console.log(renderPanel([
977
+ ` ${UI.title(`${ICON.spark} Welcome to LLM-Router CLI Agent`)}`,
978
+ ` ${UI.muted("No connection found. Let's configure authentication.")}`,
979
+ ]));
980
+ console.log("");
660
981
  const routerUrl = await input({
661
982
  message: "Router URL:",
662
- default: "https://api.llm-router.dev",
983
+ default: "https://llm-router-6o4u74bhpq-uc.a.run.app",
663
984
  validate: (v) => {
664
985
  try {
665
986
  new URL(v);
666
987
  return true;
667
988
  }
668
989
  catch {
669
- return "Enter a valid URL (e.g. https://api.llm-router.dev)";
990
+ return "Enter a valid URL (e.g. https://llm-router-6o4u74bhpq-uc.a.run.app)";
670
991
  }
671
992
  },
672
993
  });
@@ -695,7 +1016,7 @@ async function runFirstTimeSetup() {
695
1016
  });
696
1017
  process.env.LLM_ROUTER_BASE_URL = routerUrl;
697
1018
  process.env.LLM_ROUTER_API_KEY = apiKey;
698
- console.log(chalk.green("\n API key saved.\n"));
1019
+ console.log(UI.success(`\n${ICON.status} API key saved.\n`));
699
1020
  }
700
1021
  return true;
701
1022
  }
@@ -761,7 +1082,14 @@ program
761
1082
  await persistSnapshot(runtime);
762
1083
  const nonInteractive = Boolean(options.prompt) || !processStdin.isTTY;
763
1084
  if (nonInteractive) {
764
- await runNonInteractive(runtime, options);
1085
+ try {
1086
+ await runNonInteractive(runtime, options);
1087
+ }
1088
+ catch (err) {
1089
+ const msg = err instanceof Error ? err.message : String(err);
1090
+ console.error(chalk.red(`\nError: ${msg}`));
1091
+ process.exit(1);
1092
+ }
765
1093
  return;
766
1094
  }
767
1095
  await runInteractive(runtime, options);
@@ -781,7 +1109,7 @@ program
781
1109
  const { input } = await import("@inquirer/prompts");
782
1110
  routerUrl = await input({
783
1111
  message: "Router URL:",
784
- default: "https://api.llm-router.dev",
1112
+ default: "https://llm-router-6o4u74bhpq-uc.a.run.app",
785
1113
  validate: (v) => {
786
1114
  try {
787
1115
  new URL(v);
@@ -897,6 +1225,21 @@ program
897
1225
  console.log();
898
1226
  }
899
1227
  });
1228
+ // Safety net: catch any unhandled promise rejections so they display a useful
1229
+ // message instead of crashing the process silently (Node 22 terminates on them).
1230
+ // In interactive mode we log but do NOT exit — the REPL should survive stray
1231
+ // async errors (e.g. failed snapshot writes, readline hiccups).
1232
+ let _isInteractiveSession = false;
1233
+ process.on("unhandledRejection", (reason) => {
1234
+ const msg = reason instanceof Error ? reason.message : String(reason);
1235
+ if (_isInteractiveSession) {
1236
+ console.error(chalk.red(`\nAsync error (non-fatal): ${msg}`));
1237
+ }
1238
+ else {
1239
+ console.error(chalk.red(`\nUnhandled error: ${msg}`));
1240
+ process.exit(1);
1241
+ }
1242
+ });
900
1243
  program.parseAsync().catch((err) => {
901
1244
  const message = err instanceof Error ? err.message : String(err);
902
1245
  console.error(chalk.red(message));