llm-agent-cli 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { ContextManager } from "./context.js";
13
13
  import { ReplInput } from "./input.js";
14
14
  import { preprocessPrompt } from "./preprocessor.js";
15
15
  import { detectProjectContext } from "./project.js";
16
- import { renderMarkdown } from "./renderer.js";
16
+ import { renderMarkdown, renderPanel as renderPanelComponent, renderTable as renderTableComponent, contextBar as contextBarComponent, renderErrorCard, renderModelBadge, renderToolProgress, formatCost, getTheme, setTheme, getThemeName, stripAnsi as stripAnsiComponent, fitCell as fitCellComponent, } from "./renderer.js";
17
17
  import { SessionStore } from "./session.js";
18
18
  import { listAvailableTools } from "./tools/index.js";
19
19
  import { resolveProtectedPath, getRuntimeConfig } from "./tools/security.js";
@@ -21,6 +21,26 @@ 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
+ /** UI accessor — always reads from the active theme so theme switches propagate. */
25
+ function UI() {
26
+ return getTheme();
27
+ }
28
+ const ICON = {
29
+ spark: "✦",
30
+ gear: "⚙",
31
+ shield: "🛡",
32
+ folder: "📁",
33
+ network: "🌐",
34
+ model: "🧠",
35
+ stack: "🧩",
36
+ session: "🗂",
37
+ tip: "💡",
38
+ status: "●",
39
+ command: "⌘",
40
+ token: "◉",
41
+ retry: "↻",
42
+ ttft: "⚡",
43
+ };
24
44
  function isPermissionMode(value) {
25
45
  return PERMISSION_MODES.includes(value);
26
46
  }
@@ -28,58 +48,131 @@ function isOutputFormat(value) {
28
48
  return value === "text" || value === "json";
29
49
  }
30
50
  function formatUsd(value) {
31
- return `$${value.toFixed(4)}`;
51
+ return formatCost(value);
52
+ }
53
+ function stripAnsi(value) {
54
+ return stripAnsiComponent(value);
55
+ }
56
+ function badge(label, tone = "info") {
57
+ const ui = UI();
58
+ const color = tone === "success"
59
+ ? ui.success
60
+ : tone === "warning"
61
+ ? ui.warning
62
+ : tone === "danger"
63
+ ? ui.danger
64
+ : tone === "muted"
65
+ ? ui.muted
66
+ : ui.info;
67
+ return color(`◈ ${label}`);
68
+ }
69
+ function fitCell(value, width) {
70
+ return fitCellComponent(value, width);
71
+ }
72
+ function renderPanel(lines) {
73
+ return renderPanelComponent(lines);
32
74
  }
33
- function looksLikeMarkdown(text) {
34
- return /(^\s{0,3}#{1,6}\s|\*\*|`[^`]+`|^\s*[-*+]\s|\|.+\|)/m.test(text);
75
+ function printTable(headers, rows, widths) {
76
+ return renderTableComponent(headers, rows, widths);
77
+ }
78
+ function contextBar(ratio, width = 18) {
79
+ return contextBarComponent(ratio, width);
35
80
  }
36
81
  function printBanner(runtime, permissionMode) {
82
+ const ui = UI();
37
83
  const model = process.env.LLM_ROUTER_MODEL ?? "auto";
38
84
  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();
85
+ const permissionTone = permissionMode === "safe"
86
+ ? "success"
87
+ : permissionMode === "yolo"
88
+ ? "danger"
89
+ : "warning";
90
+ const headerLine = `${ui.title(`${ICON.spark} LLM-Router Agent`)} ${badge(permissionMode, permissionTone)} ${ui.muted("v2.0")}`;
91
+ const routerLine = ` ${ui.muted("router")} ${ui.info(baseUrl)}`;
92
+ const modelLine = ` ${ui.muted("model")} ${ui.success(model)}`;
93
+ const wsLine = ` ${ui.muted("workspace")} ${runtime.project.workspaceRoot}`;
94
+ const stackLine = ` ${ui.muted("stack")} ${runtime.project.language} ${ui.muted("·")} ${runtime.project.framework} ${ui.muted("·")} ${runtime.project.packageManager}`;
95
+ const sessionLine = ` ${ui.muted("session")} ${ui.muted(runtime.sessionId)}`;
96
+ const themeLine = ` ${ui.muted("theme")} ${ui.muted(getThemeName())}`;
97
+ const panel = renderPanel([
98
+ ` ${headerLine}`,
99
+ ``,
100
+ ` ${routerLine}`,
101
+ ` ${modelLine}`,
102
+ ` ${wsLine}`,
103
+ ` ${stackLine}`,
104
+ ` ${sessionLine}`,
105
+ ` ${themeLine}`,
106
+ ]);
107
+ console.log("");
108
+ console.log(panel);
109
+ console.log("");
110
+ console.log(ui.muted(` /help /tools /models /cost /compact /theme`));
111
+ console.log("");
112
+ }
113
+ function printCommandGroup(title, commands) {
114
+ console.log(UI().accent.bold(` ${title}`));
115
+ console.log(printTable(["Command", "Description"], commands.map(([cmd, desc]) => [UI().info(cmd), desc]), [28, 70]));
116
+ console.log("");
46
117
  }
47
118
  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");
119
+ console.log("");
120
+ console.log(renderPanel([` ${UI().title(`${ICON.command} Command Palette`)}`]));
121
+ console.log("");
122
+ printCommandGroup("🧭 Core", [
123
+ ["/help", "Show this help"],
124
+ ["/exit", "Quit the CLI"],
125
+ ["/clear", "Clear terminal output only"],
126
+ ["/retry", "Resend last prompt"],
127
+ ["/undo", "Remove last user+assistant turn"],
128
+ ]);
129
+ printCommandGroup("🗂 Session", [
130
+ ["/compact", "Summarize and compress conversation"],
131
+ ["/save [name]", "Save named session snapshot"],
132
+ ["/load [name]", "Load named session or list saved names"],
133
+ ["/models", "List available router models"],
134
+ ["/cost", "Show token and cost totals"],
135
+ ]);
136
+ printCommandGroup("📁 Workspace", [
137
+ ["/cd <path>", "Change working directory (within workspace root)"],
138
+ ["/tools", "List tools and descriptions"],
139
+ ["/permissions", "Show current permission mode"],
140
+ ["/verbose", "Toggle verbose tool result output"],
141
+ ["/theme <name>", "Switch UI theme: dark, light, minimal"],
142
+ ]);
143
+ printCommandGroup("🧪 Quality", [
144
+ ["/review [path]", "Run a structured code review prompt"],
145
+ ["/lint [path]", "Run linter (auto detects toolchain)"],
146
+ ["/test [path]", "Run tests (auto detects runner)"],
147
+ ["/scan [path]", "Run security scan"],
148
+ ]);
149
+ printCommandGroup("🔑 Auth", [
150
+ ["/login", "Log in (opens browser for Google/GitHub auth)"],
151
+ ["/logout", "Log out and clear credentials"],
152
+ ["/whoami", "Show current account and router URL"],
153
+ ]);
154
+ printCommandGroup("🧠 Memory", [
155
+ ["/memory", "Show global + project memory"],
156
+ ["/memory clear", "Clear project memory"],
157
+ ["/memory clear --global", "Clear global memory"],
158
+ ["/remember <text>", "Remember fact in project memory"],
159
+ ["/remember --global <text>", "Remember fact in global memory"],
160
+ ]);
72
161
  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."));
162
+ console.log(UI().accent.bold(" Input Modes"));
163
+ console.log(UI().muted(" End a line with \\ for multiline continuation"));
164
+ console.log(UI().muted(" Type <<< then paste blocks; finish with a single . line"));
76
165
  console.log();
77
166
  }
78
167
  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
- }
168
+ const tools = listAvailableTools();
169
+ const rows = tools.map((tool) => [
170
+ `${ICON.gear} ${UI().info(tool.name)}`,
171
+ tool.description,
172
+ ]);
173
+ console.log("");
174
+ console.log(UI().accent.bold(`${ICON.gear} Tools (${tools.length})`));
175
+ console.log(printTable(["Name", "Description"], rows, [30, 68]));
83
176
  console.log();
84
177
  }
85
178
  function modelFlag(model, key) {
@@ -120,22 +213,26 @@ function formatModels(models) {
120
213
  const rows = models.map((model) => ({
121
214
  id: String(model.id ?? ""),
122
215
  tier: String(model.tier ?? ""),
123
- owner: String(model.owned_by ?? ""),
216
+ provider: String(model.provider ?? model.owned_by ?? ""),
217
+ context: Number(model.contextWindow ?? model.context_window ?? 0),
124
218
  caps: extractModelCapabilities(model),
219
+ isBlocked: Boolean(model.isBlocked ?? model.is_blocked ?? false),
125
220
  }));
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");
221
+ const tierText = (tier) => {
222
+ if (tier === "strong" || tier === "reasoning")
223
+ return UI().success(tier);
224
+ if (tier === "cheap")
225
+ return UI().warning(tier);
226
+ return UI().info(tier || "unknown");
227
+ };
228
+ return printTable(["Model", "Tier", "Provider", "Context", "Capabilities", "Status"], rows.map((row) => [
229
+ `${ICON.model} ${row.id}`,
230
+ tierText(row.tier),
231
+ row.provider || "-",
232
+ row.context > 0 ? `${Math.round(row.context / 1000)}k` : "-",
233
+ row.caps || "-",
234
+ row.isBlocked ? UI().warning("blocked") : UI().success("active"),
235
+ ]), [35, 10, 14, 9, 32, 10]);
139
236
  }
140
237
  async function readAllStdin() {
141
238
  const chunks = [];
@@ -193,6 +290,10 @@ async function bootstrapRuntime(options) {
193
290
  sessionId,
194
291
  workspaceRoot: project.workspaceRoot,
195
292
  }, resumed);
293
+ const ui = UI();
294
+ const historyLen = resumed.history?.length ?? 0;
295
+ console.log(ui.success(`${ICON.session} Session restored: ${options.resume}`));
296
+ console.log(ui.muted(` ${historyLen} messages recovered, cwd: ${resumed.cwd || process.cwd()}`));
196
297
  }
197
298
  }
198
299
  return {
@@ -210,6 +311,56 @@ async function bootstrapRuntime(options) {
210
311
  async function persistSnapshot(runtime) {
211
312
  await runtime.sessions.appendSnapshot(runtime.sessionId, runtime.agent.exportState(runtime.context.serializeState()));
212
313
  }
314
+ /**
315
+ * Turn a raw tool name + args JSON into a concise, human-readable action label.
316
+ * e.g. "read_file" + '{"path":"src/index.ts"}' → "Reading src/index.ts"
317
+ */
318
+ function humanizeToolAction(toolName, argsPreview) {
319
+ // Try to extract a path or key arg from the preview
320
+ let target = "";
321
+ try {
322
+ const parsed = JSON.parse(argsPreview.replace(/\.\.\.$/, ""));
323
+ target = parsed.path || parsed.file_path || parsed.pattern || parsed.command || parsed.url || parsed.directory || "";
324
+ }
325
+ catch {
326
+ // argsPreview may be truncated / not valid JSON — try to extract a path-like string
327
+ const pathMatch = argsPreview.match(/["']?([^\s"',{}]+\.[a-z]{1,6})["']?/i)
328
+ || argsPreview.match(/["']?([^\s"',{}]{3,60})["']?/);
329
+ target = pathMatch?.[1] ?? "";
330
+ }
331
+ // Shorten long paths to last 2 segments
332
+ if (target.length > 50) {
333
+ const parts = target.split("/");
334
+ target = parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : target.slice(-47) + "…";
335
+ }
336
+ const suffix = target ? ` ${UI().info(target)}` : "";
337
+ const verbs = {
338
+ read_file: "Reading",
339
+ write_to_file: "Writing",
340
+ apply_diff: "Editing",
341
+ list_directory: "Listing",
342
+ create_directory: "Creating directory",
343
+ delete_file: "Deleting",
344
+ move_file: "Moving",
345
+ get_file_info: "Checking",
346
+ search_files: "Searching",
347
+ find_files: "Finding files",
348
+ show_diff: "Diffing",
349
+ search_and_replace_all: "Replacing in",
350
+ fetch_url: "Fetching",
351
+ run_bash_command: "Running",
352
+ git_status: "Git status",
353
+ git_diff: "Git diff",
354
+ git_log: "Git log",
355
+ git_blame: "Git blame",
356
+ git_stash: "Git stash",
357
+ run_linter: "Linting",
358
+ run_tests: "Testing",
359
+ security_scan: "Scanning",
360
+ };
361
+ const verb = verbs[toolName] ?? toolName;
362
+ return `${verb}${suffix}`;
363
+ }
213
364
  async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutput) {
214
365
  const preprocessed = await preprocessPrompt(rawPrompt, Math.min(runtime.config.maxReadBytes, 16_000));
215
366
  if (preprocessed.includedFiles.length > 0) {
@@ -231,18 +382,26 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
231
382
  process.stderr.write(`warning: ${warning}\n`);
232
383
  }
233
384
  }
234
- const spinner = ora({ text: "Thinking...", color: "cyan" }).start();
385
+ const turnStartedAt = Date.now();
386
+ let firstTokenAt = 0;
387
+ const spinner = ora({
388
+ text: `${ICON.spark} thinking...`,
389
+ color: "cyan",
390
+ spinner: "dots",
391
+ }).start();
235
392
  let printedToken = false;
236
393
  let printedAgentPrefix = false;
237
394
  let toolTimer = null;
238
395
  let toolStartAt = 0;
239
396
  let currentToolLabel = "";
397
+ let toolGroupTotal = 0;
240
398
  const clearToolTimer = () => {
241
399
  if (toolTimer) {
242
400
  clearInterval(toolTimer);
243
401
  toolTimer = null;
244
402
  }
245
403
  };
404
+ const ui = UI();
246
405
  const callbacks = {
247
406
  onToken: async (token) => {
248
407
  if (!streamOutput)
@@ -251,37 +410,55 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
251
410
  spinner.stop();
252
411
  clearToolTimer();
253
412
  if (!printedAgentPrefix) {
254
- processStdout.write(chalk.bold.white("Agent: "));
413
+ if (!firstTokenAt)
414
+ firstTokenAt = Date.now();
415
+ processStdout.write(ui.accent.bold(`${ICON.spark} Assistant `));
255
416
  printedAgentPrefix = true;
256
417
  }
257
418
  printedToken = true;
258
419
  processStdout.write(token);
259
420
  },
260
- onRetry: async (attempt, delayMs) => {
261
- spinner.text = `Retry ${attempt}/3 in ${Math.floor(delayMs / 1000)}s...`;
421
+ onRetry: async (attempt, delayMs, reason) => {
422
+ const normalizedReason = reason.replace(/\s+/g, " ").trim();
423
+ const shortReason = normalizedReason.length > 72
424
+ ? `${normalizedReason.slice(0, 69)}...`
425
+ : normalizedReason;
426
+ spinner.text = `${ICON.retry} retry ${attempt}/3 in ${Math.floor(delayMs / 1000)}s (${shortReason})`;
262
427
  },
263
428
  onToolStart: async (event) => {
264
429
  if (!streamOutput)
265
430
  return;
266
431
  clearToolTimer();
267
432
  toolStartAt = Date.now();
268
- currentToolLabel = `${event.toolName} ${event.argsPreview}`;
269
- spinner.start(`> ${currentToolLabel} (0s)`);
433
+ toolGroupTotal = event.total;
434
+ currentToolLabel = humanizeToolAction(event.toolName, event.argsPreview);
435
+ const progressPrefix = event.total > 1 ? ui.muted(`[${event.index}/${event.total}] `) : "";
436
+ spinner.start(`${progressPrefix}${chalk.dim(currentToolLabel)} (0.0s)`);
270
437
  toolTimer = setInterval(() => {
271
- const elapsed = Math.floor((Date.now() - toolStartAt) / 1000);
272
- spinner.text = `> ${currentToolLabel} (${elapsed}s)`;
273
- }, 1000);
438
+ const elapsed = ((Date.now() - toolStartAt) / 1000).toFixed(1);
439
+ spinner.text = `${progressPrefix}${chalk.dim(currentToolLabel)} (${elapsed}s)`;
440
+ }, 200);
274
441
  },
275
442
  onToolEnd: async (event) => {
276
443
  if (!streamOutput)
277
444
  return;
278
445
  clearToolTimer();
446
+ const elapsed = ((Date.now() - toolStartAt) / 1000).toFixed(1);
279
447
  spinner.stop();
448
+ const label = humanizeToolAction(event.toolName, "");
449
+ console.log(renderToolProgress(event.index, event.total, label, elapsed !== "0.0" ? `${elapsed}s` : undefined));
280
450
  if (runtime.agent.isVerboseTools()) {
281
- console.log(chalk.dim(`\n[${event.toolName}]\n${event.resultPreview}`));
451
+ console.log(ui.muted(` ${event.resultPreview.split("\n").join("\n ")}`));
282
452
  }
283
453
  else if (event.truncated) {
284
- console.log(chalk.yellow(`\nTool ${event.toolName} output was truncated before returning to model.`));
454
+ console.log(ui.warning(` Output truncated before returning to model.`));
455
+ }
456
+ // Restart spinner so the user sees progress while the agent processes
457
+ // tool results and calls the router again.
458
+ if (event.index === event.total) {
459
+ printedToken = false;
460
+ printedAgentPrefix = false;
461
+ spinner.start(`${ICON.spark} thinking...`);
285
462
  }
286
463
  },
287
464
  };
@@ -295,15 +472,11 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
295
472
  const responseText = result.response;
296
473
  if (streamOutput) {
297
474
  if (!printedToken) {
298
- console.log(chalk.bold.white("Agent:"));
475
+ console.log(UI().accent.bold(`${ICON.spark} Assistant`));
299
476
  console.log(renderMarkdown(responseText).trimEnd());
300
477
  }
301
478
  else {
302
479
  processStdout.write("\n");
303
- if (looksLikeMarkdown(responseText)) {
304
- console.log(chalk.dim("[Rendered Markdown]"));
305
- console.log(renderMarkdown(responseText).trimEnd());
306
- }
307
480
  }
308
481
  }
309
482
  if (!streamOutput && outputFormat === "text") {
@@ -311,16 +484,30 @@ async function executeTurn(runtime, rawPrompt, signal, outputFormat, streamOutpu
311
484
  }
312
485
  if (result.usage) {
313
486
  const metrics = runtime.context.recordTurn(result.model, result.usage);
487
+ const turnMs = Date.now() - turnStartedAt;
488
+ const ttftMs = firstTokenAt ? firstTokenAt - turnStartedAt : 0;
314
489
  if (streamOutput) {
315
- console.log(chalk.dim(`[↑ ${metrics.promptTokens.toLocaleString()} | ↓ ${metrics.completionTokens.toLocaleString()} | ~${formatUsd(metrics.turnCostUsd)} | total ${formatUsd(metrics.cumulativeCostUsd)}]`));
490
+ const parts = [
491
+ renderModelBadge(result.model),
492
+ `ctx ${contextBar(metrics.contextUtilization)} ${(metrics.contextUtilization * 100).toFixed(1)}%`,
493
+ `${ICON.token} in ${metrics.promptTokens.toLocaleString()}`,
494
+ `${ICON.token} out ${metrics.completionTokens.toLocaleString()}`,
495
+ `turn ${formatUsd(metrics.turnCostUsd)}`,
496
+ `total ${formatUsd(metrics.cumulativeCostUsd)}`,
497
+ ];
498
+ if (ttftMs > 0) {
499
+ parts.push(`${ICON.ttft} ${(ttftMs / 1000).toFixed(2)}s TTFT`);
500
+ }
501
+ parts.push(`${(turnMs / 1000).toFixed(1)}s`);
502
+ console.log(UI().muted(parts.join(" | ")));
316
503
  }
317
504
  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).`));
505
+ console.log(UI().warning(`Context usage warning: ${(metrics.contextUtilization * 100).toFixed(1)}% of configured window (${runtime.config.contextWindowTokens.toLocaleString()} tokens).`));
319
506
  }
320
507
  if (metrics.autoCompactRecommended) {
321
508
  const summary = await runtime.agent.compactHistory(signal);
322
509
  if (streamOutput) {
323
- console.log(chalk.yellow(`Auto-compact triggered at ${(metrics.contextUtilization * 100).toFixed(1)}% context usage.`));
510
+ console.log(UI().warning(`Auto-compact triggered at ${(metrics.contextUtilization * 100).toFixed(1)}% context usage.`));
324
511
  console.log(renderMarkdown(summary).trimEnd());
325
512
  }
326
513
  }
@@ -357,23 +544,58 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
357
544
  return { handled: true, shouldExit: true };
358
545
  case "/clear":
359
546
  console.clear();
547
+ printBanner(runtime, runtime.agent.getPermissionMode());
360
548
  return { handled: true, shouldExit: false };
361
549
  case "/tools":
362
550
  printTools();
363
551
  return { handled: true, shouldExit: false };
364
- case "/permissions":
365
- console.log(`Current permission mode: ${runtime.agent.getPermissionMode()}`);
552
+ case "/permissions": {
553
+ const mode = runtime.agent.getPermissionMode();
554
+ const policyRows = mode === "safe"
555
+ ? [
556
+ ["Writes", UI().danger("disabled")],
557
+ ["Shell", UI().danger("disabled")],
558
+ ["Approvals", UI().success("n/a")],
559
+ ]
560
+ : mode === "standard"
561
+ ? [
562
+ ["Writes", UI().warning("requires confirmation")],
563
+ ["Shell", UI().warning("requires confirmation")],
564
+ ["Approvals", UI().info("interactive")],
565
+ ]
566
+ : [
567
+ ["Writes", UI().success("enabled")],
568
+ ["Shell", UI().success("enabled")],
569
+ ["Approvals", UI().warning("auto-approved")],
570
+ ];
571
+ console.log("");
572
+ console.log(UI().accent.bold(`${ICON.shield} Permission Mode`));
573
+ console.log(printTable(["Setting", "Value"], policyRows, [18, 28]));
574
+ console.log(UI().muted(`Current mode: ${mode}`));
575
+ console.log("");
366
576
  return { handled: true, shouldExit: false };
577
+ }
367
578
  case "/verbose": {
368
579
  const next = !runtime.agent.isVerboseTools();
369
580
  runtime.agent.setVerboseTools(next);
370
- console.log(`Verbose tool output: ${next ? "ON" : "OFF"}`);
581
+ console.log(next
582
+ ? UI().success("Verbose tool output: ON")
583
+ : UI().muted("Verbose tool output: OFF"));
371
584
  await persistSnapshot(runtime);
372
585
  return { handled: true, shouldExit: false };
373
586
  }
374
587
  case "/cost": {
375
588
  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)}`);
589
+ const rows = [
590
+ ["Input tokens", summary.inputTokens.toLocaleString()],
591
+ ["Output tokens", summary.outputTokens.toLocaleString()],
592
+ ["Total tokens", summary.totalTokens.toLocaleString()],
593
+ ["Estimated cost", formatUsd(summary.totalCostUsd)],
594
+ ];
595
+ console.log("");
596
+ console.log(UI().accent.bold(`${ICON.token} Session Cost`));
597
+ console.log(printTable(["Metric", "Value"], rows, [20, 22]));
598
+ console.log("");
377
599
  return { handled: true, shouldExit: false };
378
600
  }
379
601
  case "/compact": {
@@ -384,18 +606,21 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
384
606
  }
385
607
  case "/models": {
386
608
  const models = await runtime.agent.listModels();
609
+ console.log("");
610
+ console.log(UI().accent.bold(`${ICON.model} Models (${models.length})`));
387
611
  console.log(formatModels(models));
612
+ console.log("");
388
613
  return { handled: true, shouldExit: false };
389
614
  }
390
615
  case "/undo": {
391
616
  const undone = runtime.agent.undoLastTurn();
392
617
  if (undone) {
393
618
  setLastRawPrompt(undone);
394
- console.log("Removed last user+assistant turn.");
619
+ console.log(UI().success("Removed last user+assistant turn."));
395
620
  await persistSnapshot(runtime);
396
621
  }
397
622
  else {
398
- console.log("Nothing to undo.");
623
+ console.log(UI().muted("Nothing to undo."));
399
624
  }
400
625
  return { handled: true, shouldExit: false };
401
626
  }
@@ -404,17 +629,21 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
404
629
  case "/save": {
405
630
  const name = argText || `session-${new Date().toISOString().replace(/[:.]/g, "-")}`;
406
631
  const location = await runtime.sessions.saveNamed(name, runtime.agent.exportState(runtime.context.serializeState()));
407
- console.log(`Saved session as \"${name}\" -> ${location}`);
632
+ console.log(UI().success(`Saved session "${name}"`));
633
+ console.log(UI().muted(`Path: ${location}`));
408
634
  return { handled: true, shouldExit: false };
409
635
  }
410
636
  case "/load": {
411
637
  if (!argText) {
412
638
  const names = await runtime.sessions.listNamed();
413
639
  if (names.length === 0) {
414
- console.log("No named sessions found.");
640
+ console.log(UI().muted("No named sessions found."));
415
641
  }
416
642
  else {
417
- console.log(`Named sessions: ${names.join(", ")}`);
643
+ console.log("");
644
+ console.log(UI().accent.bold(`Named Sessions (${names.length})`));
645
+ console.log(names.map((name) => ` ${UI().info(name)}`).join("\n"));
646
+ console.log("");
418
647
  }
419
648
  return { handled: true, shouldExit: false };
420
649
  }
@@ -425,7 +654,7 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
425
654
  }
426
655
  await restoreSnapshot(runtime, loaded);
427
656
  setLastRawPrompt(loaded.lastUserInput);
428
- console.log(`Loaded session \"${argText}\".`);
657
+ console.log(UI().success(`Loaded session "${argText}".`));
429
658
  await persistSnapshot(runtime);
430
659
  return { handled: true, shouldExit: false };
431
660
  }
@@ -441,7 +670,7 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
441
670
  return { handled: true, shouldExit: false };
442
671
  }
443
672
  process.chdir(path.resolve(resolved));
444
- console.log(`Working directory: ${process.cwd()}`);
673
+ console.log(UI().success(`Working directory: ${process.cwd()}`));
445
674
  await persistSnapshot(runtime);
446
675
  return { handled: true, shouldExit: false };
447
676
  }
@@ -451,11 +680,14 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
451
680
  try {
452
681
  const result = await run_linter({ path: targetPath, linter: "auto", fix: false });
453
682
  spinner.stop();
683
+ console.log("");
684
+ console.log(UI().accent.bold(`Lint Results — ${targetPath}`));
454
685
  console.log(result);
686
+ console.log("");
455
687
  }
456
688
  catch (err) {
457
689
  spinner.stop();
458
- console.log(chalk.red(`Linter error: ${err instanceof Error ? err.message : String(err)}`));
690
+ console.log(UI().danger(`Linter error: ${err instanceof Error ? err.message : String(err)}`));
459
691
  }
460
692
  return { handled: true, shouldExit: false };
461
693
  }
@@ -465,11 +697,14 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
465
697
  try {
466
698
  const result = await run_tests({ path: targetPath, runner: "auto", bail: false });
467
699
  spinner.stop();
700
+ console.log("");
701
+ console.log(UI().accent.bold(`Test Results${targetPath ? ` — ${targetPath}` : ""}`));
468
702
  console.log(result);
703
+ console.log("");
469
704
  }
470
705
  catch (err) {
471
706
  spinner.stop();
472
- console.log(chalk.red(`Test runner error: ${err instanceof Error ? err.message : String(err)}`));
707
+ console.log(UI().danger(`Test runner error: ${err instanceof Error ? err.message : String(err)}`));
473
708
  }
474
709
  return { handled: true, shouldExit: false };
475
710
  }
@@ -479,11 +714,14 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
479
714
  try {
480
715
  const result = await security_scan({ path: targetPath, include_low_severity: false });
481
716
  spinner.stop();
717
+ console.log("");
718
+ console.log(UI().accent.bold(`Security Scan — ${targetPath}`));
482
719
  console.log(result);
720
+ console.log("");
483
721
  }
484
722
  catch (err) {
485
723
  spinner.stop();
486
- console.log(chalk.red(`Scan error: ${err instanceof Error ? err.message : String(err)}`));
724
+ console.log(UI().danger(`Scan error: ${err instanceof Error ? err.message : String(err)}`));
487
725
  }
488
726
  return { handled: true, shouldExit: false };
489
727
  }
@@ -493,7 +731,10 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
493
731
  // /memory clear --global — clear global memory
494
732
  if (!argText) {
495
733
  const output = await showMemory(runtime.workspaceRoot);
734
+ console.log("");
735
+ console.log(UI().accent.bold("Memory"));
496
736
  console.log(output);
737
+ console.log("");
497
738
  return { handled: true, shouldExit: false };
498
739
  }
499
740
  const memArgs = argText.split(/\s+/);
@@ -502,17 +743,17 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
502
743
  await clearMemory(scope, runtime.workspaceRoot);
503
744
  const fresh = await loadMemory(runtime.workspaceRoot);
504
745
  runtime.agent.updateMemory(fresh);
505
- console.log(`${scope === "global" ? "Global" : "Project"} memory cleared.`);
746
+ console.log(UI().success(`${scope === "global" ? "Global" : "Project"} memory cleared.`));
506
747
  return { handled: true, shouldExit: false };
507
748
  }
508
- console.log(`Unknown /memory subcommand: ${argText}`);
749
+ console.log(UI().warning(`Unknown /memory subcommand: ${argText}`));
509
750
  return { handled: true, shouldExit: false };
510
751
  }
511
752
  case "/remember": {
512
753
  // /remember <text> — append to project memory
513
754
  // /remember --global <text> — append to global memory
514
755
  if (!argText) {
515
- console.log("Usage: /remember <text> or /remember --global <text>");
756
+ console.log(UI().warning("Usage: /remember <text> or /remember --global <text>"));
516
757
  return { handled: true, shouldExit: false };
517
758
  }
518
759
  let scope = "project";
@@ -522,14 +763,61 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
522
763
  fact = fact.slice("--global".length).trim();
523
764
  }
524
765
  if (!fact) {
525
- console.log("Nothing to remember — provide text after the flag.");
766
+ console.log(UI().warning("Nothing to remember — provide text after the flag."));
526
767
  return { handled: true, shouldExit: false };
527
768
  }
528
769
  const savedPath = await appendMemory(fact, scope, runtime.workspaceRoot);
529
770
  const fresh = await loadMemory(runtime.workspaceRoot);
530
771
  runtime.agent.updateMemory(fresh);
531
- console.log(chalk.green(`Remembered (${scope}): ${fact}`));
532
- console.log(chalk.dim(` ${savedPath}`));
772
+ console.log(UI().success(`Remembered (${scope}): ${fact}`));
773
+ console.log(UI().muted(`Path: ${savedPath}`));
774
+ return { handled: true, shouldExit: false };
775
+ }
776
+ case "/logout": {
777
+ try {
778
+ await runLogout();
779
+ console.log(UI().success("Logged out. Run /login or restart to switch accounts."));
780
+ }
781
+ catch (err) {
782
+ console.log(UI().danger(`Logout error: ${err instanceof Error ? err.message : String(err)}`));
783
+ }
784
+ return { handled: true, shouldExit: false };
785
+ }
786
+ case "/login": {
787
+ const routerUrl = process.env.LLM_ROUTER_BASE_URL || "";
788
+ try {
789
+ await runLogin({ routerUrl });
790
+ // Reload credentials into env so the agent uses the new account immediately
791
+ const conn = await resolveConnectionFromEnvOrCreds();
792
+ if (conn) {
793
+ console.log(UI().success("Logged in. New credentials active for this session."));
794
+ }
795
+ }
796
+ catch (err) {
797
+ console.log(UI().danger(`Login error: ${err instanceof Error ? err.message : String(err)}`));
798
+ }
799
+ return { handled: true, shouldExit: false };
800
+ }
801
+ case "/whoami": {
802
+ await runWhoami();
803
+ return { handled: true, shouldExit: false };
804
+ }
805
+ case "/theme": {
806
+ const validThemes = ["dark", "light", "minimal"];
807
+ if (!argText || !validThemes.includes(argText)) {
808
+ console.log("");
809
+ console.log(UI().accent.bold(`Themes`));
810
+ console.log(printTable(["Theme", "Description"], [
811
+ [getThemeName() === "dark" ? UI().success("dark (active)") : "dark", "Full color with warm accent tones"],
812
+ [getThemeName() === "light" ? UI().success("light (active)") : "light", "Muted palette for light terminals"],
813
+ [getThemeName() === "minimal" ? UI().success("minimal (active)") : "minimal", "No color, bold/dim only"],
814
+ ], [22, 45]));
815
+ console.log(UI().muted(`\nUsage: /theme <dark|light|minimal>`));
816
+ console.log("");
817
+ return { handled: true, shouldExit: false };
818
+ }
819
+ setTheme(argText);
820
+ console.log(UI().success(`Theme set to: ${argText}`));
533
821
  return { handled: true, shouldExit: false };
534
822
  }
535
823
  case "/review": {
@@ -554,7 +842,7 @@ async function handleSlashCommand(runtime, commandLine, setLastRawPrompt) {
554
842
  }
555
843
  catch (err) {
556
844
  if (!(err instanceof Error && err.name === "TurnCancelledError")) {
557
- console.log(chalk.red(`Review error: ${err instanceof Error ? err.message : String(err)}`));
845
+ console.log(UI().danger(`Review error: ${err instanceof Error ? err.message : String(err)}`));
558
846
  }
559
847
  }
560
848
  return { handled: true, shouldExit: false };
@@ -575,10 +863,16 @@ async function runNonInteractive(runtime, options) {
575
863
  await executeTurn(runtime, promptText, controller.signal, options.outputFormat, false);
576
864
  }
577
865
  async function runInteractive(runtime, options) {
866
+ _isInteractiveSession = true;
578
867
  printBanner(runtime, options.permissions);
579
868
  const health = await checkRouterHealth();
580
869
  if (!health.ok) {
581
- console.log(chalk.yellow(`Warning: ${health.message} check LLM_ROUTER_BASE_URL before running requests.`));
870
+ console.log(UI().warning(`${ICON.status} connection warning`));
871
+ console.log(UI().warning(` ${health.message} — check LLM_ROUTER_BASE_URL.`));
872
+ console.log();
873
+ }
874
+ else {
875
+ console.log(UI().success(`${ICON.status} router connection healthy.`));
582
876
  console.log();
583
877
  }
584
878
  const replInput = new ReplInput(runtime.paths.historyFile, runtime.config.historyLimit);
@@ -602,7 +896,25 @@ async function runInteractive(runtime, options) {
602
896
  });
603
897
  });
604
898
  while (true) {
605
- const userInput = await replInput.prompt();
899
+ // ora spinners write ANSI codes to stdout while readline manages the same
900
+ // stream, which can leave the readline interface in a closed/corrupt state
901
+ // (especially on Node.js 22+). Always reset after a turn so the next
902
+ // prompt() call starts with a fresh, healthy interface.
903
+ let userInput;
904
+ try {
905
+ userInput = await replInput.prompt();
906
+ }
907
+ catch {
908
+ try {
909
+ await replInput.resetInterface();
910
+ userInput = await replInput.prompt();
911
+ }
912
+ catch {
913
+ // Truly unrecoverable (e.g. stdin closed / Ctrl-D); exit gracefully.
914
+ console.log(chalk.yellow("\nSession ended."));
915
+ return;
916
+ }
917
+ }
606
918
  const trimmed = userInput.trim();
607
919
  if (!trimmed)
608
920
  continue;
@@ -617,18 +929,19 @@ async function runInteractive(runtime, options) {
617
929
  }
618
930
  if (trimmed === "/retry") {
619
931
  if (!lastRawPrompt) {
620
- console.log("No previous prompt to retry.");
932
+ console.log(UI().warning("No previous prompt to retry."));
621
933
  continue;
622
934
  }
623
935
  }
624
936
  else {
625
- console.log(`Unknown command: ${trimmed}`);
937
+ console.log(UI().warning(`Unknown command: ${trimmed}`));
938
+ console.log(UI().muted(`${ICON.tip} Run /help to view available commands.`));
626
939
  continue;
627
940
  }
628
941
  }
629
942
  const rawPrompt = trimmed === "/retry" ? lastRawPrompt ?? "" : userInput;
630
943
  if (!rawPrompt.trim()) {
631
- console.log("No previous prompt to retry.");
944
+ console.log(UI().warning("No previous prompt to retry."));
632
945
  continue;
633
946
  }
634
947
  lastRawPrompt = rawPrompt;
@@ -642,31 +955,52 @@ async function runInteractive(runtime, options) {
642
955
  }
643
956
  else {
644
957
  const message = err instanceof Error ? err.message : String(err);
645
- console.log(chalk.red(`Error: ${message}`));
958
+ // Contextual error suggestions
959
+ let suggestion;
960
+ if (message.includes("504") || message.includes("timeout")) {
961
+ suggestion = "The upstream model timed out. Try a faster model with --model or check /models.";
962
+ }
963
+ else if (message.includes("context") || message.includes("token")) {
964
+ suggestion = "Try /compact to reduce context usage, or start a new session.";
965
+ }
966
+ else if (message.includes("429") || message.includes("rate")) {
967
+ suggestion = "Rate limited. Wait a moment and try again, or switch models.";
968
+ }
969
+ else if (message.includes("401") || message.includes("auth")) {
970
+ suggestion = "Authentication failed. Try /login or check your API key.";
971
+ }
972
+ console.log(renderErrorCard(message, suggestion));
646
973
  }
647
974
  }
648
975
  finally {
649
976
  activeTurnController = null;
650
977
  }
651
978
  console.log();
979
+ // Reset readline interface after every turn so ora spinner side-effects
980
+ // don't accumulate and corrupt the next prompt() call.
981
+ await replInput.resetInterface();
652
982
  }
653
983
  await replInput.close();
654
984
  }
655
985
  // ── First-run setup wizard ────────────────────────────────────────────────────
656
986
  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"));
987
+ const { input, select } = await import("@inquirer/prompts");
988
+ console.log("");
989
+ console.log(renderPanel([
990
+ ` ${UI().title(`${ICON.spark} Welcome to LLM-Router CLI Agent`)}`,
991
+ ` ${UI().muted("No connection found. Let's configure authentication.")}`,
992
+ ]));
993
+ console.log("");
660
994
  const routerUrl = await input({
661
995
  message: "Router URL:",
662
- default: "https://api.llm-router.dev",
996
+ default: "https://llm-router-6o4u74bhpq-uc.a.run.app",
663
997
  validate: (v) => {
664
998
  try {
665
999
  new URL(v);
666
1000
  return true;
667
1001
  }
668
1002
  catch {
669
- return "Enter a valid URL (e.g. https://api.llm-router.dev)";
1003
+ return "Enter a valid URL (e.g. https://llm-router-6o4u74bhpq-uc.a.run.app)";
670
1004
  }
671
1005
  },
672
1006
  });
@@ -695,7 +1029,7 @@ async function runFirstTimeSetup() {
695
1029
  });
696
1030
  process.env.LLM_ROUTER_BASE_URL = routerUrl;
697
1031
  process.env.LLM_ROUTER_API_KEY = apiKey;
698
- console.log(chalk.green("\n API key saved.\n"));
1032
+ console.log(UI().success(`\n${ICON.status} API key saved.\n`));
699
1033
  }
700
1034
  return true;
701
1035
  }
@@ -761,7 +1095,14 @@ program
761
1095
  await persistSnapshot(runtime);
762
1096
  const nonInteractive = Boolean(options.prompt) || !processStdin.isTTY;
763
1097
  if (nonInteractive) {
764
- await runNonInteractive(runtime, options);
1098
+ try {
1099
+ await runNonInteractive(runtime, options);
1100
+ }
1101
+ catch (err) {
1102
+ const msg = err instanceof Error ? err.message : String(err);
1103
+ console.error(chalk.red(`\nError: ${msg}`));
1104
+ process.exit(1);
1105
+ }
765
1106
  return;
766
1107
  }
767
1108
  await runInteractive(runtime, options);
@@ -781,7 +1122,7 @@ program
781
1122
  const { input } = await import("@inquirer/prompts");
782
1123
  routerUrl = await input({
783
1124
  message: "Router URL:",
784
- default: "https://api.llm-router.dev",
1125
+ default: "https://llm-router-6o4u74bhpq-uc.a.run.app",
785
1126
  validate: (v) => {
786
1127
  try {
787
1128
  new URL(v);
@@ -897,6 +1238,21 @@ program
897
1238
  console.log();
898
1239
  }
899
1240
  });
1241
+ // Safety net: catch any unhandled promise rejections so they display a useful
1242
+ // message instead of crashing the process silently (Node 22 terminates on them).
1243
+ // In interactive mode we log but do NOT exit — the REPL should survive stray
1244
+ // async errors (e.g. failed snapshot writes, readline hiccups).
1245
+ let _isInteractiveSession = false;
1246
+ process.on("unhandledRejection", (reason) => {
1247
+ const msg = reason instanceof Error ? reason.message : String(reason);
1248
+ if (_isInteractiveSession) {
1249
+ console.error(chalk.red(`\nAsync error (non-fatal): ${msg}`));
1250
+ }
1251
+ else {
1252
+ console.error(chalk.red(`\nUnhandled error: ${msg}`));
1253
+ process.exit(1);
1254
+ }
1255
+ });
900
1256
  program.parseAsync().catch((err) => {
901
1257
  const message = err instanceof Error ? err.message : String(err);
902
1258
  console.error(chalk.red(message));